Compare commits
12
Commits
0cdede325e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ffff592b1
|
||
|
|
cdaa62682a
|
||
|
|
5888ca8982
|
||
|
|
6068a0ed9f
|
||
|
|
a482d96b29
|
||
|
|
a714bbab85
|
||
|
|
4b7569b844
|
||
|
|
8b736c1d22
|
||
|
|
99232887c5
|
||
|
|
d658da2e99
|
||
|
|
e6bb65d37b
|
||
|
|
d84cdc55ab
|
No files matched your search
@@ -0,0 +1,64 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Core Design Principles
|
||||||
|
- **Principle of Least Surprise**: All UI behaviors should be predictable and intuitive
|
||||||
|
- **Dead Simple**: Complexity in storage, simplicity in interaction
|
||||||
|
- **Progressive Disclosure**: Basic operations stay simple, advanced features are optional
|
||||||
|
- **Backward Compatible**: Existing commands work unchanged on Default list
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
- **Build project**: `cargo build`
|
||||||
|
- **Build release**: `cargo build --release`
|
||||||
|
- **Run locally**: `cargo run -- <subcommand> <args>` (e.g., `cargo run -- add "test item"`)
|
||||||
|
- **Run tests**: `cargo test`
|
||||||
|
- **Check code**: `cargo check`
|
||||||
|
- **Format code**: `cargo fmt`
|
||||||
|
- **Lint code**: `cargo clippy`
|
||||||
|
- **Install from source**: `cargo build --release && cp target/release/ldr ~/.local/bin/`
|
||||||
|
|
||||||
|
## Project Architecture
|
||||||
|
|
||||||
|
LDR is a Rust CLI productivity application implementing the "append-and-review" note system with Markdown format and subtask support. The architecture follows a clean separation of concerns:
|
||||||
|
|
||||||
|
### Core Structure
|
||||||
|
- **main.rs**: Entry point with CLI definition using `clap`. Handles XDG-compliant data directory setup (`~/.local/share/ldr/`) and automatic migration from plain text to Markdown format
|
||||||
|
- **commands.rs**: Command implementations with terminal I/O, file operations, and colored output using `termion`. Handles subtask operations and task reference parsing (1, 2a, 3b format)
|
||||||
|
- **markdown.rs**: Core data structures and parsing/generation for Markdown todo files. Supports single-level nesting (tasks with subtasks) and multiple named lists
|
||||||
|
- **migration.rs**: One-time migration utilities for converting plain text files to Markdown format with Default list structure
|
||||||
|
- **content.rs**: Legacy compatibility functions maintained for existing tests
|
||||||
|
- **input.rs**: Raw keyboard input handling for interactive review mode (currently simplified)
|
||||||
|
|
||||||
|
### File Format
|
||||||
|
- **todos.md**: Markdown file with multiple lists (Default list is primary)
|
||||||
|
- **archive.md**: Markdown archive with date-based sections and list organization
|
||||||
|
- **Migration**: Automatic one-time conversion from `note.txt`/`archive.txt` to Markdown format
|
||||||
|
|
||||||
|
### Task System
|
||||||
|
- **Task References**: Number+letter format (1, 2a, 3b) for referencing tasks and subtasks
|
||||||
|
- **Single-Level Nesting**: Tasks can have subtasks, but subtasks cannot have sub-subtasks
|
||||||
|
- **Default List**: Primary list for backward compatibility - all existing commands work on Default
|
||||||
|
- **Multiple Lists**: Support for named lists (Work, Personal, etc.) with `--list` flag
|
||||||
|
|
||||||
|
### Key Design Patterns
|
||||||
|
- **Markdown Storage**: Human-readable format with proper structure
|
||||||
|
- **Flat Numbering**: Simple number+letter system for task references
|
||||||
|
- **Backward Compatibility**: Existing interface unchanged, new features are additive
|
||||||
|
- **XDG Compliance**: Uses `xdg` crate for proper data directory handling
|
||||||
|
- **Migration on First Run**: Seamless upgrade from old format with backups
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
1. CLI parsing extracts subcommands and arguments
|
||||||
|
2. Migration check and execution if needed (first run only)
|
||||||
|
3. XDG directories resolved for Markdown files (`todos.md`, `archive.md`)
|
||||||
|
4. Commands parse task references and delegate to Markdown operations
|
||||||
|
5. File I/O operations handle Markdown parsing/generation
|
||||||
|
6. Terminal output provides user feedback with colors and proper formatting
|
||||||
|
|
||||||
|
### Task Reference System
|
||||||
|
- **1**: Task 1 and all its subtasks
|
||||||
|
- **1a**: Only subtask 'a' of task 1
|
||||||
|
- **2b**: Only subtask 'b' of task 2
|
||||||
|
- Operations default to whole tasks, subtask references are specific
|
||||||
Generated
+254
-18
@@ -2,6 +2,21 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android-tzdata"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.19"
|
version = "0.6.19"
|
||||||
@@ -53,10 +68,16 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "anyhow"
|
||||||
version = "1.3.2"
|
version = "1.0.99"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
@@ -64,12 +85,41 @@ version = "2.9.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
|
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f"
|
||||||
|
dependencies = [
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.41"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||||
|
dependencies = [
|
||||||
|
"android-tzdata",
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.41"
|
version = "4.5.41"
|
||||||
@@ -116,6 +166,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.13"
|
version = "0.3.13"
|
||||||
@@ -150,6 +206,30 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.63"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.1"
|
version = "1.70.1"
|
||||||
@@ -157,9 +237,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ldr"
|
name = "js-sys"
|
||||||
version = "1.0.2"
|
version = "0.3.77"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ldr"
|
||||||
|
version = "1.0.3"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"termion",
|
"termion",
|
||||||
@@ -174,11 +266,11 @@ checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.0.2"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607"
|
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.1",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
]
|
]
|
||||||
@@ -190,10 +282,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numtoa"
|
name = "log"
|
||||||
version = "0.1.0"
|
version = "0.4.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
|
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numtoa"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
@@ -233,11 +340,11 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.4.1"
|
version = "0.5.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
|
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -252,13 +359,25 @@ version = "1.0.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.1",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -291,9 +410,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termion"
|
name = "termion"
|
||||||
version = "2.0.3"
|
version = "4.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d"
|
checksum = "3669a69de26799d6321a5aa713f55f7e2cd37bd47be044b50f2acafc42c122bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"libredox",
|
"libredox",
|
||||||
@@ -322,6 +441,123 @@ dependencies = [
|
|||||||
"wit-bindgen-rt",
|
"wit-bindgen-rt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-backend"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"log",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-backend",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -401,7 +637,7 @@ version = "0.39.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.9.1",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+6
-4
@@ -1,19 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ldr"
|
name = "ldr"
|
||||||
version = "1.0.2"
|
version = "1.0.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Log, Do, Review - A simple todo system"
|
description = "Log, Do, Review - A simple todo system"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
repository = "https://github.com/swaits/ldr"
|
repository = "https://git.swaits.com/swaits/ldr"
|
||||||
homepage = "https://github.com/swaits/ldr"
|
homepage = "https://git.swaits.com/swaits/ldr"
|
||||||
keywords = ["todo", "cli", "productivity"]
|
keywords = ["todo", "cli", "productivity"]
|
||||||
categories = ["command-line-utilities"]
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
clap = { version = "4.5.41", features = ["derive"] }
|
clap = { version = "4.5.41", features = ["derive"] }
|
||||||
xdg = "3.0.0"
|
xdg = "3.0.0"
|
||||||
termion = "2.0"
|
termion = "4.0.5"
|
||||||
|
chrono = "0.4.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.20.0"
|
tempfile = "3.20.0"
|
||||||
+696
-443
File diff suppressed because it is too large.
Load diff
+16
-11
@@ -1,19 +1,24 @@
|
|||||||
//! Pure functions for content manipulation.
|
//! Pure functions for content manipulation (legacy compatibility).
|
||||||
//!
|
//!
|
||||||
//! This module contains stateless functions that handle the core logic
|
//! This module maintains backward compatibility with existing tests while
|
||||||
//! for manipulating todo item content without side effects.
|
//! the main functionality has moved to the markdown module for the new format.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// Pure function that prepends new text to existing content.
|
/// Pure function that prepends new text to existing content (legacy format).
|
||||||
/// Adds the new text at the beginning followed by a newline and the trimmed existing content.
|
/// This is maintained for backward compatibility with existing tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn add_entry_to_content(existing_content: &str, text: &str) -> String {
|
pub fn add_entry_to_content(existing_content: &str, text: &str) -> String {
|
||||||
|
if existing_content.trim().is_empty() {
|
||||||
|
format!("{}\n", text)
|
||||||
|
} else {
|
||||||
format!("{}\n{}", text, existing_content.trim_end())
|
format!("{}\n{}", text, existing_content.trim_end())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure function that moves specified lines to the top of the content.
|
/// Pure function that moves specified lines to the top of the content (legacy format).
|
||||||
/// Takes 1-based line numbers, validates them, removes duplicates, and reorders content.
|
/// This is maintained for backward compatibility with existing tests.
|
||||||
/// Returns the new content string and a vector of the prioritized items.
|
#[allow(dead_code)]
|
||||||
pub fn prioritize_items_in_content(
|
pub fn prioritize_items_in_content(
|
||||||
lines: &[String],
|
lines: &[String],
|
||||||
numbers: &[usize],
|
numbers: &[usize],
|
||||||
@@ -64,9 +69,9 @@ pub fn prioritize_items_in_content(
|
|||||||
Ok((new_content, to_prioritize))
|
Ok((new_content, to_prioritize))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure function that removes specified lines from content.
|
/// Pure function that removes specified lines from content (legacy format).
|
||||||
/// Takes 1-based line numbers, validates them, and returns remaining content.
|
/// This is maintained for backward compatibility with existing tests.
|
||||||
/// Returns the new content string and a vector of the archived items.
|
#[allow(dead_code)]
|
||||||
pub fn archive_items_in_content(
|
pub fn archive_items_in_content(
|
||||||
lines: &[String],
|
lines: &[String],
|
||||||
numbers: &[usize],
|
numbers: &[usize],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use termion::raw::IntoRawMode;
|
|||||||
/// Reads raw keyboard input including arrow keys and special characters.
|
/// Reads raw keyboard input including arrow keys and special characters.
|
||||||
/// Handles ANSI escape sequences for arrow keys and returns string representations.
|
/// Handles ANSI escape sequences for arrow keys and returns string representations.
|
||||||
/// Used for interactive navigation in review mode.
|
/// Used for interactive navigation in review mode.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn read_key_input() -> io::Result<String> {
|
pub fn read_key_input() -> io::Result<String> {
|
||||||
let _stdout = io::stdout().into_raw_mode()?;
|
let _stdout = io::stdout().into_raw_mode()?;
|
||||||
let mut buffer = [0; 3];
|
let mut buffer = [0; 3];
|
||||||
|
|||||||
+48
-22
@@ -3,13 +3,15 @@
|
|||||||
//! A command-line todo application that emphasizes adding items to the top
|
//! A command-line todo application that emphasizes adding items to the top
|
||||||
//! and provides an interactive review mode for processing items.
|
//! and provides an interactive review mode for processing items.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::io;
|
|
||||||
use xdg::BaseDirectories;
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod content;
|
mod content;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod markdown;
|
||||||
|
mod migration;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -32,6 +34,9 @@ enum Commands {
|
|||||||
Add {
|
Add {
|
||||||
/// The text to add (e.g., "Read: Book XYZ")
|
/// The text to add (e.g., "Read: Book XYZ")
|
||||||
text: String,
|
text: String,
|
||||||
|
/// Add as subtask under this task number (e.g., --under 3)
|
||||||
|
#[arg(long)]
|
||||||
|
under: Option<usize>,
|
||||||
},
|
},
|
||||||
/// List the top N items (default 5)
|
/// List the top N items (default 5)
|
||||||
#[command(aliases = ["l", "list"])]
|
#[command(aliases = ["l", "list"])]
|
||||||
@@ -47,50 +52,71 @@ enum Commands {
|
|||||||
/// Raise the priority of items (move toward top)
|
/// Raise the priority of items (move toward top)
|
||||||
#[command(aliases = ["u", "prioritize"])]
|
#[command(aliases = ["u", "prioritize"])]
|
||||||
Up {
|
Up {
|
||||||
/// Item numbers to prioritize (from "ldr ls")
|
/// Item references to prioritize (e.g., "1", "2a", "3b")
|
||||||
numbers: Vec<usize>,
|
refs: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Archive completed items
|
/// Archive completed items
|
||||||
#[command(aliases = ["d", "done", "finish", "check"])]
|
#[command(aliases = ["d", "done", "finish", "check"])]
|
||||||
Do {
|
Do {
|
||||||
/// Item numbers to archive (from "ldr ls")
|
/// Item references to archive (e.g., "1", "2a", "3b")
|
||||||
numbers: Vec<usize>,
|
refs: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Remove items without archiving
|
/// Remove items without archiving
|
||||||
#[command(aliases = ["remove", "delete", "destroy", "forget"])]
|
#[command(aliases = ["remove", "delete", "destroy", "forget"])]
|
||||||
Rm {
|
Rm {
|
||||||
/// Item numbers to remove (from "ldr ls")
|
/// Item references to remove (e.g., "1", "2a", "3b")
|
||||||
numbers: Vec<usize>,
|
refs: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Scan and review items interactively, from top to bottom
|
|
||||||
#[command(aliases = ["s", "r", "review"])]
|
|
||||||
Scan,
|
|
||||||
/// Edit items in your $EDITOR
|
/// Edit items in your $EDITOR
|
||||||
#[command(alias = "e")]
|
#[command(aliases = ["e", "s", "scan", "r", "review"])]
|
||||||
Edit,
|
Edit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entry point that parses CLI arguments and dispatches to appropriate command handlers.
|
/// Entry point that parses CLI arguments and dispatches to appropriate command handlers.
|
||||||
/// Sets up XDG-compliant data directory paths for note.txt and archive.txt files.
|
/// Sets up XDG-compliant data directory paths and handles migration from plain text format.
|
||||||
fn main() -> io::Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let base = BaseDirectories::with_prefix("ldr");
|
let base = BaseDirectories::with_prefix("ldr");
|
||||||
|
|
||||||
|
// Old plain text file paths
|
||||||
let note_path = base
|
let note_path = base
|
||||||
.place_data_file("note.txt")
|
.place_data_file("note.txt")
|
||||||
.expect("Failed to create data directory");
|
.context("Failed to create data directory for note.txt")?;
|
||||||
let archive_path = base
|
let archive_path = base
|
||||||
.place_data_file("archive.txt")
|
.place_data_file("archive.txt")
|
||||||
.expect("Failed to create data directory");
|
.context("Failed to create data directory for archive.txt")?;
|
||||||
|
|
||||||
|
// New Markdown file paths
|
||||||
|
let todo_md_path = base
|
||||||
|
.place_data_file("todos.md")
|
||||||
|
.context("Failed to create data directory for todos.md")?;
|
||||||
|
let archive_md_path = base
|
||||||
|
.place_data_file("archive.md")
|
||||||
|
.context("Failed to create data directory for archive.md")?;
|
||||||
|
|
||||||
|
// Check if migration is needed and perform it
|
||||||
|
if migration::needs_migration(¬e_path, &archive_path, &todo_md_path, &archive_md_path) {
|
||||||
|
migration::perform_migration(¬e_path, &archive_path, &todo_md_path, &archive_md_path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Migration from plain text to Markdown failed: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Add { text } => commands::add_entry(¬e_path, &text),
|
Commands::Add { text, under } => {
|
||||||
|
commands::add_entry(&todo_md_path, &text, under).context("Failed to add entry")?
|
||||||
|
}
|
||||||
Commands::Ls { num, all, filter } => {
|
Commands::Ls { num, all, filter } => {
|
||||||
commands::list_note(¬e_path, num, all, filter.as_deref())
|
commands::list_note(&todo_md_path, num, all, filter.as_deref())
|
||||||
|
.context("Failed to list notes")?
|
||||||
}
|
}
|
||||||
Commands::Up { numbers } => commands::prioritize_items(¬e_path, &numbers),
|
Commands::Up { refs } => commands::prioritize_items(&todo_md_path, &refs)
|
||||||
Commands::Do { numbers } => commands::archive_items(¬e_path, &archive_path, &numbers),
|
.context("Failed to prioritize items")?,
|
||||||
Commands::Rm { numbers } => commands::remove_items(¬e_path, &numbers),
|
Commands::Do { refs } => commands::archive_items(&todo_md_path, &archive_md_path, &refs)
|
||||||
Commands::Scan => commands::review_note(¬e_path, &archive_path),
|
.context("Failed to archive items")?,
|
||||||
Commands::Edit => commands::edit_note(¬e_path),
|
Commands::Rm { refs } => {
|
||||||
|
commands::remove_items(&todo_md_path, &refs).context("Failed to remove items")?
|
||||||
}
|
}
|
||||||
|
Commands::Edit => commands::edit_note(&todo_md_path).context("Failed to edit note")?,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
+612
@@ -0,0 +1,612 @@
|
|||||||
|
//! Markdown parsing and generation for todo files.
|
||||||
|
//!
|
||||||
|
//! This module handles reading and writing Markdown-formatted todo and archive files.
|
||||||
|
//! It supports single-level nesting (tasks with subtasks) and multiple named lists.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Represents a single task with optional subtasks
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Task {
|
||||||
|
pub text: String,
|
||||||
|
pub subtasks: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
pub fn new(text: String) -> Self {
|
||||||
|
Task {
|
||||||
|
text,
|
||||||
|
subtasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn with_subtasks(text: String, subtasks: Vec<String>) -> Self {
|
||||||
|
Task { text, subtasks }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subtask(&mut self, subtask: String) {
|
||||||
|
self.subtasks.push(subtask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn has_subtasks(&self) -> bool {
|
||||||
|
!self.subtasks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn subtask_count(&self) -> usize {
|
||||||
|
self.subtasks.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the entire todo file with a single task list
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TodoFile {
|
||||||
|
pub title: String,
|
||||||
|
pub tasks: Vec<Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TodoFile {
|
||||||
|
pub fn new(title: String) -> Self {
|
||||||
|
TodoFile {
|
||||||
|
title,
|
||||||
|
tasks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_task(&mut self, task: Task) {
|
||||||
|
self.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepend_task(&mut self, task: Task) {
|
||||||
|
self.tasks.insert(0, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tasks.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn task_count(&self) -> usize {
|
||||||
|
self.tasks.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse task reference in format "1", "2a", "10b", etc.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TaskRef {
|
||||||
|
pub task_index: usize,
|
||||||
|
pub subtask_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskRef {
|
||||||
|
pub fn parse(input: &str) -> Result<TaskRef, String> {
|
||||||
|
if input.is_empty() {
|
||||||
|
return Err("Empty task reference".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<char> = input.chars().collect();
|
||||||
|
let mut task_part = String::new();
|
||||||
|
let mut subtask_char = None;
|
||||||
|
|
||||||
|
for (i, &ch) in chars.iter().enumerate() {
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
task_part.push(ch);
|
||||||
|
} else if ch.is_ascii_lowercase() {
|
||||||
|
if i == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"Task reference must start with a number: {}",
|
||||||
|
input
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if subtask_char.is_some() {
|
||||||
|
return Err(format!("Multiple subtask letters not allowed: {}", input));
|
||||||
|
}
|
||||||
|
subtask_char = Some(ch);
|
||||||
|
} else {
|
||||||
|
return Err(format!("Invalid character in task reference: {}", ch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if task_part.is_empty() {
|
||||||
|
return Err(format!("No task number found: {}", input));
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_num = task_part
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| format!("Invalid task number: {}", task_part))?;
|
||||||
|
|
||||||
|
// Validate task number range
|
||||||
|
if task_num == 0 {
|
||||||
|
return Err("Task number must be at least 1".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TASK_NUM: usize = 10000;
|
||||||
|
if task_num > MAX_TASK_NUM {
|
||||||
|
return Err(format!(
|
||||||
|
"Task number too large: {}. Maximum is {}",
|
||||||
|
task_num, MAX_TASK_NUM
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_index = task_num - 1; // Convert to 0-based
|
||||||
|
|
||||||
|
let subtask_index = subtask_char.map(|ch| (ch as usize) - ('a' as usize));
|
||||||
|
|
||||||
|
Ok(TaskRef {
|
||||||
|
task_index,
|
||||||
|
subtask_index,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_subtask(&self) -> bool {
|
||||||
|
self.subtask_index.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a markdown todo file with resilient handling of user edits
|
||||||
|
pub fn parse_todo_file(content: &str) -> Result<TodoFile, String> {
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
if lines.is_empty() {
|
||||||
|
return Ok(TodoFile::new("TODOs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut todo_file = TodoFile::new("TODOs".to_string());
|
||||||
|
let mut current_task: Option<Task> = None;
|
||||||
|
let mut warned_about_deep_nesting = false;
|
||||||
|
|
||||||
|
for (line_num, line) in lines.iter().enumerate() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle title - be flexible with spacing
|
||||||
|
if let Some(title) = trimmed.strip_prefix("# ") {
|
||||||
|
todo_file.title = title.trim().to_string();
|
||||||
|
} else if trimmed.starts_with("#") && !trimmed.starts_with("##") {
|
||||||
|
// Handle cases where user might not have space after # (but not ##)
|
||||||
|
let title = &trimmed[1..];
|
||||||
|
todo_file.title = title.trim().to_string();
|
||||||
|
}
|
||||||
|
// Skip any list headers - we ignore them now since we don't support multiple lists
|
||||||
|
else if trimmed.starts_with("##") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check for deep nesting first - warn and convert to level 1 subtask
|
||||||
|
else if (line.starts_with(" ") || line.starts_with("\t\t"))
|
||||||
|
&& (line.trim_start().starts_with("- ")
|
||||||
|
|| line.trim_start().starts_with("* ")
|
||||||
|
|| line.trim_start().starts_with("+ "))
|
||||||
|
{
|
||||||
|
// This is a deeply nested item - warn once and treat as level 1 subtask
|
||||||
|
if !warned_about_deep_nesting {
|
||||||
|
eprintln!("Warning: Deep nesting detected (line {}). Converting to level 1 subtask. Only single-level subtasks are supported.", line_num + 1);
|
||||||
|
warned_about_deep_nesting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item_text = line
|
||||||
|
.trim_start()
|
||||||
|
.strip_prefix("- ")
|
||||||
|
.or_else(|| line.trim_start().strip_prefix("* "))
|
||||||
|
.or_else(|| line.trim_start().strip_prefix("+ "))
|
||||||
|
.unwrap_or(line.trim_start());
|
||||||
|
|
||||||
|
if let Some(ref mut task) = current_task {
|
||||||
|
task.add_subtask(item_text.trim().to_string());
|
||||||
|
} else {
|
||||||
|
// If no current task, treat as main task
|
||||||
|
current_task = Some(Task::new(item_text.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle subtasks - be flexible with indentation (2, 3, or 4 spaces, or single tab)
|
||||||
|
else if let Some(subtask_text) = line.strip_prefix(" - ") {
|
||||||
|
if let Some(ref mut task) = current_task {
|
||||||
|
task.add_subtask(subtask_text.trim().to_string());
|
||||||
|
} else {
|
||||||
|
// If no current task, treat as main task (user error but be forgiving)
|
||||||
|
current_task = Some(Task::new(subtask_text.trim().to_string()));
|
||||||
|
}
|
||||||
|
} else if let Some(subtask_text) = line.strip_prefix(" - ") {
|
||||||
|
// Handle 3-space indentation
|
||||||
|
if let Some(ref mut task) = current_task {
|
||||||
|
task.add_subtask(subtask_text.trim().to_string());
|
||||||
|
} else {
|
||||||
|
current_task = Some(Task::new(subtask_text.trim().to_string()));
|
||||||
|
}
|
||||||
|
} else if let Some(subtask_text) = line.strip_prefix(" - ") {
|
||||||
|
// Handle 4-space indentation
|
||||||
|
if let Some(ref mut task) = current_task {
|
||||||
|
task.add_subtask(subtask_text.trim().to_string());
|
||||||
|
} else {
|
||||||
|
current_task = Some(Task::new(subtask_text.trim().to_string()));
|
||||||
|
}
|
||||||
|
} else if let Some(subtask_text) = line.strip_prefix("\t- ") {
|
||||||
|
// Handle single tab indentation
|
||||||
|
if let Some(ref mut task) = current_task {
|
||||||
|
task.add_subtask(subtask_text.trim().to_string());
|
||||||
|
} else {
|
||||||
|
current_task = Some(Task::new(subtask_text.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle main tasks - flexible with spacing and different bullet styles
|
||||||
|
else if let Some(task_text) = trimmed.strip_prefix("- ") {
|
||||||
|
// Save previous task if exists
|
||||||
|
if let Some(task) = current_task.take() {
|
||||||
|
todo_file.add_task(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_task = Some(Task::new(task_text.trim().to_string()));
|
||||||
|
} else if let Some(task_text) = trimmed.strip_prefix("* ") {
|
||||||
|
// Handle asterisk bullet points
|
||||||
|
if let Some(task) = current_task.take() {
|
||||||
|
todo_file.add_task(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_task = Some(Task::new(task_text.trim().to_string()));
|
||||||
|
} else if let Some(task_text) = trimmed.strip_prefix("+ ") {
|
||||||
|
// Handle plus bullet points
|
||||||
|
if let Some(task) = current_task.take() {
|
||||||
|
todo_file.add_task(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_task = Some(Task::new(task_text.trim().to_string()));
|
||||||
|
}
|
||||||
|
// Handle non-markdown lines gracefully - ignore unknown formatting
|
||||||
|
else if !trimmed.is_empty() {
|
||||||
|
// If it looks like it might be a task without proper formatting, treat it as one
|
||||||
|
if !trimmed.starts_with('#') && !trimmed.starts_with('<') && !trimmed.contains("```") {
|
||||||
|
// Save previous task if exists
|
||||||
|
if let Some(task) = current_task.take() {
|
||||||
|
todo_file.add_task(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_task = Some(Task::new(trimmed.to_string()));
|
||||||
|
}
|
||||||
|
// Otherwise just skip unknown lines (comments, HTML, code blocks, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save final task
|
||||||
|
if let Some(task) = current_task {
|
||||||
|
todo_file.add_task(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(todo_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate markdown content from a TodoFile
|
||||||
|
pub fn generate_todo_file(todo_file: &TodoFile) -> String {
|
||||||
|
let mut content = String::new();
|
||||||
|
content.push_str(&format!("# {}\n\n", todo_file.title));
|
||||||
|
|
||||||
|
for task in &todo_file.tasks {
|
||||||
|
content.push_str(&format!("- {}\n", task.text));
|
||||||
|
for subtask in &task.subtasks {
|
||||||
|
content.push_str(&format!(" - {}\n", subtask));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an archive file with date-based sections
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ArchiveFile {
|
||||||
|
pub title: String,
|
||||||
|
pub entries: Vec<ArchiveEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ArchiveEntry {
|
||||||
|
pub date: String,
|
||||||
|
pub lists: HashMap<String, Vec<Task>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchiveFile {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
ArchiveFile {
|
||||||
|
title: "Archive".to_string(),
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_items_for_today(&mut self, list_name: &str, tasks: Vec<Task>) {
|
||||||
|
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
// Find today's entry position
|
||||||
|
let entry_pos = self.entries.iter().position(|e| e.date == today);
|
||||||
|
|
||||||
|
if let Some(pos) = entry_pos {
|
||||||
|
// Entry exists, add tasks to it
|
||||||
|
self.entries[pos]
|
||||||
|
.lists
|
||||||
|
.entry(list_name.to_string())
|
||||||
|
.or_default()
|
||||||
|
.extend(tasks);
|
||||||
|
} else {
|
||||||
|
// Create new entry for today
|
||||||
|
let mut lists = HashMap::new();
|
||||||
|
lists.insert(list_name.to_string(), tasks);
|
||||||
|
self.entries.insert(0, ArchiveEntry { date: today, lists });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an archive file
|
||||||
|
pub fn parse_archive_file(content: &str) -> Result<ArchiveFile, String> {
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
if lines.is_empty() {
|
||||||
|
return Ok(ArchiveFile::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut archive = ArchiveFile::new();
|
||||||
|
let mut current_entry: Option<ArchiveEntry> = None;
|
||||||
|
let mut current_list_name = "Default".to_string();
|
||||||
|
let mut current_task: Option<Task> = None;
|
||||||
|
|
||||||
|
for (line_num, line) in lines.iter().enumerate() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(title) = trimmed.strip_prefix("# ") {
|
||||||
|
archive.title = title.to_string();
|
||||||
|
} else if let Some(date) = trimmed.strip_prefix("## ") {
|
||||||
|
// Save previous task and entry
|
||||||
|
if let (Some(mut entry), Some(task)) = (current_entry.take(), current_task.take()) {
|
||||||
|
entry
|
||||||
|
.lists
|
||||||
|
.entry(current_list_name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(task);
|
||||||
|
archive.entries.push(entry);
|
||||||
|
} else if let Some(entry) = current_entry.take() {
|
||||||
|
archive.entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_entry = Some(ArchiveEntry {
|
||||||
|
date: date.to_string(),
|
||||||
|
lists: HashMap::new(),
|
||||||
|
});
|
||||||
|
current_list_name = "Default".to_string();
|
||||||
|
} else if let Some(list_name) = trimmed.strip_prefix("### ") {
|
||||||
|
// Save previous task
|
||||||
|
if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take())
|
||||||
|
{
|
||||||
|
entry
|
||||||
|
.lists
|
||||||
|
.entry(current_list_name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_list_name = list_name.to_string();
|
||||||
|
} else if let Some(task_text) = trimmed.strip_prefix("- ") {
|
||||||
|
// Save previous task
|
||||||
|
if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take())
|
||||||
|
{
|
||||||
|
entry
|
||||||
|
.lists
|
||||||
|
.entry(current_list_name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
current_task = Some(Task::new(task_text.to_string()));
|
||||||
|
} else if let Some(subtask_text) = trimmed.strip_prefix(" - ") {
|
||||||
|
if let Some(ref mut task) = current_task {
|
||||||
|
task.add_subtask(subtask_text.to_string());
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"Subtask found without parent task at line {}: {}",
|
||||||
|
line_num + 1,
|
||||||
|
trimmed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if !trimmed.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid archive format at line {}: {}",
|
||||||
|
line_num + 1,
|
||||||
|
trimmed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save final task and entry
|
||||||
|
if let Some(mut entry) = current_entry {
|
||||||
|
if let Some(task) = current_task {
|
||||||
|
entry.lists.entry(current_list_name).or_default().push(task);
|
||||||
|
}
|
||||||
|
archive.entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate archive file content
|
||||||
|
pub fn generate_archive_file(archive: &ArchiveFile) -> String {
|
||||||
|
let mut content = String::new();
|
||||||
|
content.push_str(&format!("# {}\n\n", archive.title));
|
||||||
|
|
||||||
|
for entry in &archive.entries {
|
||||||
|
content.push_str(&format!("## {}\n", entry.date));
|
||||||
|
|
||||||
|
// Add Default list items first (without header)
|
||||||
|
if let Some(default_tasks) = entry.lists.get("Default") {
|
||||||
|
for task in default_tasks {
|
||||||
|
content.push_str(&format!("- {}\n", task.text));
|
||||||
|
for subtask in &task.subtasks {
|
||||||
|
content.push_str(&format!(" - {}\n", subtask));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other lists with headers
|
||||||
|
for (list_name, tasks) in &entry.lists {
|
||||||
|
if list_name != "Default" && !tasks.is_empty() {
|
||||||
|
content.push_str(&format!("\n### {}\n", list_name));
|
||||||
|
for task in tasks {
|
||||||
|
content.push_str(&format!("- {}\n", task.text));
|
||||||
|
for subtask in &task.subtasks {
|
||||||
|
content.push_str(&format!(" - {}\n", subtask));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
content.trim_end().to_string() + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_ref_parsing() {
|
||||||
|
assert_eq!(
|
||||||
|
TaskRef::parse("1").unwrap(),
|
||||||
|
TaskRef {
|
||||||
|
task_index: 0,
|
||||||
|
subtask_index: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
TaskRef::parse("5a").unwrap(),
|
||||||
|
TaskRef {
|
||||||
|
task_index: 4,
|
||||||
|
subtask_index: Some(0)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
TaskRef::parse("10z").unwrap(),
|
||||||
|
TaskRef {
|
||||||
|
task_index: 9,
|
||||||
|
subtask_index: Some(25)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(TaskRef::parse("").is_err());
|
||||||
|
assert!(TaskRef::parse("a").is_err());
|
||||||
|
assert!(TaskRef::parse("1A").is_err());
|
||||||
|
assert!(TaskRef::parse("1ab").is_err());
|
||||||
|
assert!(TaskRef::parse("1-2").is_err());
|
||||||
|
|
||||||
|
// Test validation edge cases
|
||||||
|
assert!(TaskRef::parse("0").is_err()); // Zero task number
|
||||||
|
assert!(TaskRef::parse("10001").is_err()); // Too large task number
|
||||||
|
assert!(TaskRef::parse("999999999999999999999").is_err()); // Integer overflow
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_simple_todo_file() {
|
||||||
|
let content = r#"# TODOs
|
||||||
|
|
||||||
|
- First task
|
||||||
|
- Second task
|
||||||
|
- Subtask A
|
||||||
|
- Subtask B
|
||||||
|
- Third task
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let todo_file = parse_todo_file(content).unwrap();
|
||||||
|
assert_eq!(todo_file.title, "TODOs");
|
||||||
|
assert_eq!(todo_file.tasks.len(), 3);
|
||||||
|
assert_eq!(todo_file.tasks[1].subtasks.len(), 2);
|
||||||
|
assert_eq!(todo_file.tasks[1].subtasks[0], "Subtask A");
|
||||||
|
assert_eq!(todo_file.tasks[1].subtasks[1], "Subtask B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_todo_file() {
|
||||||
|
let mut todo_file = TodoFile::new("TODOs".to_string());
|
||||||
|
|
||||||
|
let mut task = Task::new("Task with subtasks".to_string());
|
||||||
|
task.add_subtask("Subtask 1".to_string());
|
||||||
|
task.add_subtask("Subtask 2".to_string());
|
||||||
|
todo_file.add_task(task);
|
||||||
|
|
||||||
|
let generated = generate_todo_file(&todo_file);
|
||||||
|
let expected = r#"# TODOs
|
||||||
|
|
||||||
|
- Task with subtasks
|
||||||
|
- Subtask 1
|
||||||
|
- Subtask 2
|
||||||
|
"#;
|
||||||
|
assert_eq!(generated, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handle_deep_nesting_gracefully() {
|
||||||
|
let content = r#"# TODOs
|
||||||
|
|
||||||
|
- Task
|
||||||
|
- Subtask
|
||||||
|
- Deep subtask
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// Resilient parser should ignore deep nesting rather than error
|
||||||
|
let todo_file = parse_todo_file(content).unwrap();
|
||||||
|
// The resilient parser treats 4 spaces as valid subtask indentation
|
||||||
|
// So "Deep subtask" becomes a subtask rather than being ignored
|
||||||
|
assert_eq!(todo_file.tasks.len(), 1);
|
||||||
|
assert_eq!(todo_file.tasks[0].text, "Task");
|
||||||
|
assert_eq!(todo_file.tasks[0].subtasks.len(), 2);
|
||||||
|
assert_eq!(todo_file.tasks[0].subtasks[0], "Subtask");
|
||||||
|
assert_eq!(todo_file.tasks[0].subtasks[1], "Deep subtask");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resilient_parsing_various_formats() {
|
||||||
|
let content = r#"# TODOs
|
||||||
|
|
||||||
|
- Task with dash
|
||||||
|
* Task with asterisk
|
||||||
|
+ Task with plus
|
||||||
|
- Subtask with 2 spaces
|
||||||
|
- Subtask with 3 spaces
|
||||||
|
- Subtask with 4 spaces
|
||||||
|
- Subtask with tab
|
||||||
|
|
||||||
|
Plain text task without bullet
|
||||||
|
- Normal task
|
||||||
|
|
||||||
|
<!-- This is a comment -->
|
||||||
|
```
|
||||||
|
This is a code block
|
||||||
|
```
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let todo_file = parse_todo_file(content).unwrap();
|
||||||
|
assert_eq!(todo_file.title, "TODOs");
|
||||||
|
|
||||||
|
// Now all tasks are in the single task list
|
||||||
|
assert_eq!(todo_file.tasks.len(), 6); // All tasks are top-level now (including code block content)
|
||||||
|
|
||||||
|
// First task (dash) has no subtasks
|
||||||
|
assert_eq!(todo_file.tasks[0].text, "Task with dash");
|
||||||
|
assert_eq!(todo_file.tasks[0].subtasks.len(), 0);
|
||||||
|
|
||||||
|
// Second task (asterisk) has no subtasks
|
||||||
|
assert_eq!(todo_file.tasks[1].text, "Task with asterisk");
|
||||||
|
assert_eq!(todo_file.tasks[1].subtasks.len(), 0);
|
||||||
|
|
||||||
|
// Third task (plus) has all the subtasks (different indentation styles)
|
||||||
|
assert_eq!(todo_file.tasks[2].text, "Task with plus");
|
||||||
|
assert_eq!(todo_file.tasks[2].subtasks.len(), 4);
|
||||||
|
|
||||||
|
// Plain text task and normal task
|
||||||
|
assert_eq!(todo_file.tasks[3].text, "Plain text task without bullet");
|
||||||
|
assert_eq!(todo_file.tasks[4].text, "Normal task");
|
||||||
|
assert_eq!(todo_file.tasks[5].text, "This is a code block");
|
||||||
|
// Comments are ignored but code block content is parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Migration utilities for converting plain text files to Markdown format.
|
||||||
|
//!
|
||||||
|
//! This module handles the one-time conversion from the old plain text format
|
||||||
|
//! to the new Markdown format with Default list structure.
|
||||||
|
|
||||||
|
use crate::markdown::{ArchiveFile, Task, TodoFile};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Check if migration is needed (plain text files exist but Markdown files don't)
|
||||||
|
pub fn needs_migration(
|
||||||
|
note_path: &Path,
|
||||||
|
archive_path: &Path,
|
||||||
|
todo_md_path: &Path,
|
||||||
|
archive_md_path: &Path,
|
||||||
|
) -> bool {
|
||||||
|
let has_old_files = note_path.exists() || archive_path.exists();
|
||||||
|
let has_new_files = todo_md_path.exists() || archive_md_path.exists();
|
||||||
|
|
||||||
|
has_old_files && !has_new_files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate plain text note.txt to Markdown todos.md
|
||||||
|
pub fn migrate_note_file(note_path: &Path) -> Result<TodoFile, String> {
|
||||||
|
if !note_path.exists() {
|
||||||
|
return Ok(create_empty_todo_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
fs::read_to_string(note_path).map_err(|e| format!("Failed to read note file: {}", e))?;
|
||||||
|
|
||||||
|
let mut todo_file = TodoFile::new("TODOs".to_string());
|
||||||
|
|
||||||
|
// Split content into lines and create tasks
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let task = Task::new(trimmed.to_string());
|
||||||
|
todo_file.add_task(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(todo_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate plain text archive.txt to Markdown archive.md
|
||||||
|
pub fn migrate_archive_file(archive_path: &Path) -> Result<ArchiveFile, String> {
|
||||||
|
if !archive_path.exists() {
|
||||||
|
return Ok(ArchiveFile::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(archive_path)
|
||||||
|
.map_err(|e| format!("Failed to read archive file: {}", e))?;
|
||||||
|
|
||||||
|
let mut archive_file = ArchiveFile::new();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
|
||||||
|
// Split content into lines and create tasks for today's date
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let task = Task::new(trimmed.to_string());
|
||||||
|
tasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tasks.is_empty() {
|
||||||
|
archive_file.add_items_for_today("Default", tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(archive_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty todo file
|
||||||
|
pub fn create_empty_todo_file() -> TodoFile {
|
||||||
|
TodoFile::new("TODOs".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create backup files before migration
|
||||||
|
pub fn create_backups(note_path: &Path, archive_path: &Path) -> Result<(), String> {
|
||||||
|
if note_path.exists() {
|
||||||
|
let backup_path = note_path.with_extension("txt.bak");
|
||||||
|
fs::copy(note_path, &backup_path)
|
||||||
|
.map_err(|e| format!("Failed to backup note file to {:?}: {}", backup_path, e))?;
|
||||||
|
println!("Created backup: {:?}", backup_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if archive_path.exists() {
|
||||||
|
let backup_path = archive_path.with_extension("txt.bak");
|
||||||
|
fs::copy(archive_path, &backup_path)
|
||||||
|
.map_err(|e| format!("Failed to backup archive file to {:?}: {}", backup_path, e))?;
|
||||||
|
println!("Created backup: {:?}", backup_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the complete migration process
|
||||||
|
pub fn perform_migration(
|
||||||
|
note_path: &Path,
|
||||||
|
archive_path: &Path,
|
||||||
|
todo_md_path: &Path,
|
||||||
|
archive_md_path: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
println!("🔄 Migrating from plain text to Markdown format...");
|
||||||
|
|
||||||
|
// Create backups first
|
||||||
|
create_backups(note_path, archive_path)?;
|
||||||
|
|
||||||
|
// Migrate note file
|
||||||
|
let todo_file = migrate_note_file(note_path)?;
|
||||||
|
let todo_content = crate::markdown::generate_todo_file(&todo_file);
|
||||||
|
|
||||||
|
fs::write(todo_md_path, todo_content)
|
||||||
|
.map_err(|e| format!("Failed to write todos.md: {}", e))?;
|
||||||
|
|
||||||
|
// Migrate archive file
|
||||||
|
let archive_file = migrate_archive_file(archive_path)?;
|
||||||
|
let archive_content = crate::markdown::generate_archive_file(&archive_file);
|
||||||
|
|
||||||
|
fs::write(archive_md_path, archive_content)
|
||||||
|
.map_err(|e| format!("Failed to write archive.md: {}", e))?;
|
||||||
|
|
||||||
|
// Count migrated items
|
||||||
|
let todo_count = todo_file.task_count();
|
||||||
|
let archive_count = archive_file
|
||||||
|
.entries
|
||||||
|
.first()
|
||||||
|
.and_then(|e| e.lists.get("Default"))
|
||||||
|
.map(|tasks| tasks.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
println!("✅ Migration completed successfully!");
|
||||||
|
println!(" • Migrated {} todo items", todo_count);
|
||||||
|
println!(" • Migrated {} archive items", archive_count);
|
||||||
|
println!(" • Created todos.md and archive.md");
|
||||||
|
println!(" • Original files backed up with .bak extension");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
fn create_test_file_with_content(content: &str) -> NamedTempFile {
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
write!(file, "{}", content).unwrap();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migrate_note_file() {
|
||||||
|
let content = "First task\nSecond task\nThird task\n";
|
||||||
|
let file = create_test_file_with_content(content);
|
||||||
|
|
||||||
|
let result = migrate_note_file(file.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.title, "TODOs");
|
||||||
|
assert_eq!(result.tasks.len(), 3);
|
||||||
|
assert_eq!(result.tasks[0].text, "First task");
|
||||||
|
assert_eq!(result.tasks[1].text, "Second task");
|
||||||
|
assert_eq!(result.tasks[2].text, "Third task");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migrate_empty_note_file() {
|
||||||
|
let file = create_test_file_with_content("");
|
||||||
|
let result = migrate_note_file(file.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.title, "TODOs");
|
||||||
|
assert_eq!(result.tasks.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migrate_archive_file() {
|
||||||
|
let content = "Completed task 1\nCompleted task 2\n";
|
||||||
|
let file = create_test_file_with_content(content);
|
||||||
|
|
||||||
|
let result = migrate_archive_file(file.path()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.title, "Archive");
|
||||||
|
assert_eq!(result.entries.len(), 1);
|
||||||
|
|
||||||
|
let entry = &result.entries[0];
|
||||||
|
let tasks = entry.lists.get("Default").unwrap();
|
||||||
|
assert_eq!(tasks.len(), 2);
|
||||||
|
assert_eq!(tasks[0].text, "Completed task 1");
|
||||||
|
assert_eq!(tasks[1].text, "Completed task 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migrate_nonexistent_files() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let nonexistent = temp_dir.path().join("nonexistent.txt");
|
||||||
|
|
||||||
|
let todo_result = migrate_note_file(&nonexistent).unwrap();
|
||||||
|
assert!(todo_result.is_empty());
|
||||||
|
|
||||||
|
let archive_result = migrate_archive_file(&nonexistent).unwrap();
|
||||||
|
assert!(archive_result.entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_needs_migration() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let note_path = temp_dir.path().join("note.txt");
|
||||||
|
let archive_path = temp_dir.path().join("archive.txt");
|
||||||
|
let todo_md_path = temp_dir.path().join("todos.md");
|
||||||
|
let archive_md_path = temp_dir.path().join("archive.md");
|
||||||
|
|
||||||
|
// No files exist - no migration needed
|
||||||
|
assert!(!needs_migration(
|
||||||
|
¬e_path,
|
||||||
|
&archive_path,
|
||||||
|
&todo_md_path,
|
||||||
|
&archive_md_path
|
||||||
|
));
|
||||||
|
|
||||||
|
// Create old file
|
||||||
|
fs::write(¬e_path, "test").unwrap();
|
||||||
|
|
||||||
|
// Old file exists, no new files - migration needed
|
||||||
|
assert!(needs_migration(
|
||||||
|
¬e_path,
|
||||||
|
&archive_path,
|
||||||
|
&todo_md_path,
|
||||||
|
&archive_md_path
|
||||||
|
));
|
||||||
|
|
||||||
|
// Create new file too
|
||||||
|
fs::write(&todo_md_path, "test").unwrap();
|
||||||
|
|
||||||
|
// Both old and new files exist - no migration needed
|
||||||
|
assert!(!needs_migration(
|
||||||
|
¬e_path,
|
||||||
|
&archive_path,
|
||||||
|
&todo_md_path,
|
||||||
|
&archive_md_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-40
@@ -240,18 +240,32 @@ mod list_tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
/// Helper function to create temporary test files with content
|
/// Helper function to create temporary test files with content (legacy)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn create_test_file_with_content(content: &str) -> NamedTempFile {
|
fn create_test_file_with_content(content: &str) -> NamedTempFile {
|
||||||
let mut file = NamedTempFile::new().unwrap();
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
write!(file, "{}", content).unwrap();
|
write!(file, "{}", content).unwrap();
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function to create Markdown todo files for new tests
|
||||||
|
fn create_markdown_test_file(tasks: &[&str]) -> NamedTempFile {
|
||||||
|
let mut content = String::from("# TODOs\n\n");
|
||||||
|
for task in tasks {
|
||||||
|
content.push_str(&format!("- {}\n", task));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
write!(file, "{}", content).unwrap();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
/// Tests listing all items when all flag is true
|
/// Tests listing all items when all flag is true
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_with_all_flag() {
|
fn test_list_with_all_flag() {
|
||||||
let content = "item 1\nitem 2\nitem 3\nitem 4\nitem 5\nitem 6";
|
let file = create_markdown_test_file(&[
|
||||||
let file = create_test_file_with_content(content);
|
"item 1", "item 2", "item 3", "item 4", "item 5", "item 6",
|
||||||
|
]);
|
||||||
|
|
||||||
// Test that all=true shows all items regardless of num
|
// Test that all=true shows all items regardless of num
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
@@ -263,8 +277,9 @@ mod list_tests {
|
|||||||
/// Tests respecting num limit when all flag is false
|
/// Tests respecting num limit when all flag is false
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_without_all_flag() {
|
fn test_list_without_all_flag() {
|
||||||
let content = "item 1\nitem 2\nitem 3\nitem 4\nitem 5\nitem 6";
|
let file = create_markdown_test_file(&[
|
||||||
let file = create_test_file_with_content(content);
|
"item 1", "item 2", "item 3", "item 4", "item 5", "item 6",
|
||||||
|
]);
|
||||||
|
|
||||||
// Test that all=false respects num limit
|
// Test that all=false respects num limit
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
@@ -276,8 +291,12 @@ mod list_tests {
|
|||||||
/// Tests filtering with all flag shows all matching items
|
/// Tests filtering with all flag shows all matching items
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_with_filter_and_all() {
|
fn test_list_with_filter_and_all() {
|
||||||
let content = "read: book 1\nwrite: article\nread: book 2\ntask: cleanup";
|
let file = create_markdown_test_file(&[
|
||||||
let file = create_test_file_with_content(content);
|
"read: book 1",
|
||||||
|
"write: article",
|
||||||
|
"read: book 2",
|
||||||
|
"task: cleanup",
|
||||||
|
]);
|
||||||
|
|
||||||
// Test that all=true with filter shows all matching items
|
// Test that all=true with filter shows all matching items
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
@@ -289,7 +308,7 @@ mod list_tests {
|
|||||||
/// Tests handling of empty files
|
/// Tests handling of empty files
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_empty_file() {
|
fn test_list_empty_file() {
|
||||||
let file = create_test_file_with_content("");
|
let file = create_markdown_test_file(&[]);
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
list_note(file.path(), 5, false, None).unwrap();
|
list_note(file.path(), 5, false, None).unwrap();
|
||||||
@@ -449,91 +468,126 @@ mod remove_tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
/// Helper function to create temporary test files with content
|
/// Helper function to create temporary test files with content (legacy)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn create_test_file_with_content(content: &str) -> NamedTempFile {
|
fn create_test_file_with_content(content: &str) -> NamedTempFile {
|
||||||
let mut file = NamedTempFile::new().unwrap();
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
write!(file, "{}", content).unwrap();
|
write!(file, "{}", content).unwrap();
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function to create Markdown todo files for new tests
|
||||||
|
fn create_markdown_test_file(tasks: &[&str]) -> NamedTempFile {
|
||||||
|
let mut content = String::from("# TODOs\n\n");
|
||||||
|
for task in tasks {
|
||||||
|
content.push_str(&format!("- {}\n", task));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
write!(file, "{}", content).unwrap();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to create Markdown todo files with subtasks
|
||||||
|
fn create_markdown_test_file_with_subtasks(
|
||||||
|
tasks_with_subtasks: &[(&str, &[&str])],
|
||||||
|
) -> NamedTempFile {
|
||||||
|
let mut content = String::from("# TODOs\n\n");
|
||||||
|
for (task, subtasks) in tasks_with_subtasks {
|
||||||
|
content.push_str(&format!("- {}\n", task));
|
||||||
|
for subtask in *subtasks {
|
||||||
|
content.push_str(&format!(" - {}\n", subtask));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = NamedTempFile::new().unwrap();
|
||||||
|
write!(file, "{}", content).unwrap();
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
/// Tests removing a single item
|
/// Tests removing a single item
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_single_item() {
|
fn test_remove_single_item() {
|
||||||
let content = "First item\nSecond item\nThird item\n";
|
let file = create_markdown_test_file(&["First item", "Second item", "Third item"]);
|
||||||
let file = create_test_file_with_content(content);
|
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[2]).unwrap();
|
remove_items(file.path(), &["2".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Verify the item was removed from the file
|
// Verify the item was removed from the file
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, "First item\nThird item\n");
|
let expected_content = "# TODOs\n\n- First item\n- Third item\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests removing multiple items
|
/// Tests removing multiple items
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_multiple_items() {
|
fn test_remove_multiple_items() {
|
||||||
let content = "First item\nSecond item\nThird item\nFourth item\n";
|
let file =
|
||||||
let file = create_test_file_with_content(content);
|
create_markdown_test_file(&["First item", "Second item", "Third item", "Fourth item"]);
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[1, 3]).unwrap();
|
remove_items(file.path(), &["1".to_string(), "3".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Verify the items were removed from the file
|
// Verify the items were removed from the file
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, "Second item\nFourth item\n");
|
let expected_content = "# TODOs\n\n- Second item\n- Fourth item\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests removing all items results in empty file
|
/// Tests removing all items results in empty file
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_all_items() {
|
fn test_remove_all_items() {
|
||||||
let content = "First item\nSecond item\n";
|
let file = create_markdown_test_file(&["First item", "Second item"]);
|
||||||
let file = create_test_file_with_content(content);
|
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[1, 2]).unwrap();
|
remove_items(file.path(), &["1".to_string(), "2".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Verify the file is empty
|
// Verify the file has only the header
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, "");
|
let expected_content = "# TODOs\n\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests handling of invalid item numbers
|
/// Tests handling of invalid item numbers
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_invalid_item_number() {
|
fn test_remove_invalid_item_number() {
|
||||||
let content = "First item\nSecond item\n";
|
let file = create_markdown_test_file(&["First item", "Second item"]);
|
||||||
let file = create_test_file_with_content(content);
|
let original_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[3]).unwrap();
|
remove_items(file.path(), &["3".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Verify the file content is unchanged
|
// Verify the file content is unchanged
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, content);
|
assert_eq!(updated_content, original_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests handling of duplicate item numbers
|
/// Tests handling of duplicate item numbers
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_duplicate_item_numbers() {
|
fn test_remove_duplicate_item_numbers() {
|
||||||
let content = "First item\nSecond item\nThird item\n";
|
let file = create_markdown_test_file(&["First item", "Second item", "Third item"]);
|
||||||
let file = create_test_file_with_content(content);
|
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[2, 2, 1]).unwrap();
|
remove_items(
|
||||||
|
file.path(),
|
||||||
|
&["2".to_string(), "2".to_string(), "1".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Verify only unique items were removed
|
// Verify only unique items were removed
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, "Third item\n");
|
let expected_content = "# TODOs\n\n- Third item\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests handling of non-existent file
|
/// Tests handling of non-existent file
|
||||||
@@ -543,7 +597,7 @@ mod remove_tests {
|
|||||||
let nonexistent_path = temp_dir.path().join("nonexistent.txt");
|
let nonexistent_path = temp_dir.path().join("nonexistent.txt");
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(&nonexistent_path, &[1]).unwrap();
|
remove_items(&nonexistent_path, &["1".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
@@ -554,28 +608,28 @@ mod remove_tests {
|
|||||||
/// Tests handling of empty file
|
/// Tests handling of empty file
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_from_empty_file() {
|
fn test_remove_from_empty_file() {
|
||||||
let file = create_test_file_with_content("");
|
let file = create_markdown_test_file(&[]);
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[1]).unwrap();
|
remove_items(file.path(), &["1".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Verify the file remains empty
|
// Verify the file remains with just the header
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, "");
|
let expected_content = "# TODOs\n\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tests that remove doesn't create an archive file (unlike do command)
|
/// Tests that remove doesn't create an archive file (unlike do command)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_does_not_archive() {
|
fn test_remove_does_not_archive() {
|
||||||
let content = "First item\nSecond item\n";
|
let file = create_markdown_test_file(&["First item", "Second item"]);
|
||||||
let file = create_test_file_with_content(content);
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
let archive_path = temp_dir.path().join("archive.txt");
|
let archive_path = temp_dir.path().join("archive.md");
|
||||||
|
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| {
|
||||||
remove_items(file.path(), &[1]).unwrap();
|
remove_items(file.path(), &["1".to_string()]).unwrap();
|
||||||
});
|
});
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
@@ -584,6 +638,45 @@ mod remove_tests {
|
|||||||
|
|
||||||
// Verify the item was removed from the original file
|
// Verify the item was removed from the original file
|
||||||
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
assert_eq!(updated_content, "Second item\n");
|
let expected_content = "# TODOs\n\n- Second item\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests that completing all subtasks of a task automatically completes the parent task
|
||||||
|
#[test]
|
||||||
|
fn test_auto_complete_parent_when_all_subtasks_done() {
|
||||||
|
let file = create_markdown_test_file_with_subtasks(&[
|
||||||
|
("Task A", &[]), // Task with no subtasks
|
||||||
|
("Task B", &["Subtask 1", "Subtask 2"]), // Task with 2 subtasks
|
||||||
|
("Task C", &["Another subtask"]), // Task with 1 subtask
|
||||||
|
]);
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let archive_path = temp_dir.path().join("archive.md");
|
||||||
|
|
||||||
|
// Archive both subtasks of Task B (2a and 2b)
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
archive_items(
|
||||||
|
file.path(),
|
||||||
|
&archive_path,
|
||||||
|
&["2a".to_string(), "2b".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify Task B was auto-completed and removed from todo file
|
||||||
|
let updated_content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
|
let expected_content = "# TODOs\n\n- Task A\n- Task C\n - Another subtask\n";
|
||||||
|
assert_eq!(updated_content, expected_content);
|
||||||
|
|
||||||
|
// Verify both subtasks AND the parent task are in the archive
|
||||||
|
assert!(archive_path.exists());
|
||||||
|
let archive_content = std::fs::read_to_string(&archive_path).unwrap();
|
||||||
|
|
||||||
|
// Should contain both subtasks as individual items and the parent task
|
||||||
|
assert!(archive_content.contains("- Subtask 1"));
|
||||||
|
assert!(archive_content.contains("- Subtask 2"));
|
||||||
|
assert!(archive_content.contains("- Task B"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
//! Integration tests for the LDR CLI application
|
||||||
|
//!
|
||||||
|
//! These tests verify the entire application works correctly by running actual CLI commands
|
||||||
|
//! with temporary data directories. Tests cover all functionality and edge cases according to POLS.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Test fixture that provides isolated temporary directories for each test
|
||||||
|
struct TestEnv {
|
||||||
|
_temp_dir: TempDir,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
binary_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestEnv {
|
||||||
|
fn new() -> Self {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||||
|
let data_dir = temp_dir.path().join("ldr");
|
||||||
|
fs::create_dir_all(&data_dir).expect("Failed to create ldr data directory");
|
||||||
|
|
||||||
|
// Build the binary if it doesn't exist or is out of date
|
||||||
|
let binary_path = Self::ensure_binary_built();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
_temp_dir: temp_dir,
|
||||||
|
data_dir,
|
||||||
|
binary_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_binary_built() -> PathBuf {
|
||||||
|
let output = Command::new("cargo")
|
||||||
|
.args(&["build", "--bin", "ldr"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to build ldr binary");
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
panic!(
|
||||||
|
"Failed to build binary: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut binary_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
binary_path.push("target/debug/ldr");
|
||||||
|
binary_path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run ldr command with arguments in the isolated environment
|
||||||
|
fn run_ldr(&self, args: &[&str]) -> CommandResult {
|
||||||
|
let output = Command::new(&self.binary_path)
|
||||||
|
.args(args)
|
||||||
|
.env("XDG_DATA_HOME", &self.data_dir)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute ldr command");
|
||||||
|
|
||||||
|
CommandResult {
|
||||||
|
status: output.status.code().unwrap_or(-1),
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path to the todos.md file
|
||||||
|
fn todos_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("ldr/todos.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path to the archive.md file
|
||||||
|
fn archive_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("ldr/archive.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the contents of todos.md
|
||||||
|
fn read_todos(&self) -> String {
|
||||||
|
fs::read_to_string(self.todos_path()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the contents of archive.md
|
||||||
|
fn read_archive(&self) -> String {
|
||||||
|
fs::read_to_string(self.archive_path()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CommandResult {
|
||||||
|
status: i32,
|
||||||
|
stdout: String,
|
||||||
|
stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandResult {
|
||||||
|
fn assert_success(&self) {
|
||||||
|
if self.status != 0 {
|
||||||
|
panic!(
|
||||||
|
"Command failed with status {}: stderr: {}",
|
||||||
|
self.status, self.stderr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_failure(&self) {
|
||||||
|
// For LDR, failure is indicated by error messages in stdout, not exit code
|
||||||
|
if !self.stdout.contains("Invalid")
|
||||||
|
&& !self.stderr.contains("Error")
|
||||||
|
&& !self.stdout.is_empty()
|
||||||
|
{
|
||||||
|
panic!("Command unexpectedly succeeded: stdout: {}", self.stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod basic_operations {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_and_list_basic() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add first item
|
||||||
|
let result = env.run_ldr(&["add", "First task"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("✓ Added: First task"));
|
||||||
|
|
||||||
|
// Add second item (should go to top)
|
||||||
|
let result = env.run_ldr(&["add", "Second task"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// List items
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Check that second task appears first (POLS: newest items at top)
|
||||||
|
// Note: Output includes ANSI color codes, so we check for task text
|
||||||
|
assert!(result.stdout.contains("1. Second task"));
|
||||||
|
assert!(result.stdout.contains("2. First task"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_list() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("No notes yet."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_with_limits() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add 10 items
|
||||||
|
for i in 1..=10 {
|
||||||
|
let result = env.run_ldr(&["add", &format!("Task {}", i)]);
|
||||||
|
result.assert_success();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default limit (5)
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
let line_count = result.stdout.lines().count();
|
||||||
|
// Should show 5 items plus "... and X more items" line
|
||||||
|
assert!(line_count >= 5);
|
||||||
|
assert!(result.stdout.contains("and 5 more items"));
|
||||||
|
|
||||||
|
// Test custom limit
|
||||||
|
let result = env.run_ldr(&["ls", "-n", "3"]);
|
||||||
|
result.assert_success();
|
||||||
|
let lines: Vec<&str> = result.stdout.lines().collect();
|
||||||
|
assert!(lines.len() >= 3);
|
||||||
|
|
||||||
|
// Test show all
|
||||||
|
let result = env.run_ldr(&["ls", "--all"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(!result.stdout.contains("more items"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod subtask_operations {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_subtask() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add main task
|
||||||
|
let result = env.run_ldr(&["add", "Main task"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Add subtask
|
||||||
|
let result = env.run_ldr(&["add", "Subtask A", "--under", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result
|
||||||
|
.stdout
|
||||||
|
.contains("✓ Added subtask to task 1: Subtask A"));
|
||||||
|
|
||||||
|
// Add another subtask
|
||||||
|
let result = env.run_ldr(&["add", "Subtask B", "--under", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// List and verify structure
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Verify main task and subtasks appear in order added
|
||||||
|
assert!(result.stdout.contains("1. Main task"));
|
||||||
|
assert!(result.stdout.contains("a. Subtask A")); // First subtask
|
||||||
|
assert!(result.stdout.contains("b. Subtask B")); // Second subtask
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_subtask_invalid_parent() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Try to add subtask to non-existent task
|
||||||
|
let result = env.run_ldr(&["add", "Orphan subtask", "--under", "1"]);
|
||||||
|
result.assert_failure();
|
||||||
|
assert!(result.stderr.contains("Invalid task number: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtask_numbering() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add main task
|
||||||
|
env.run_ldr(&["add", "Task with many subtasks"]);
|
||||||
|
|
||||||
|
// Add multiple subtasks
|
||||||
|
for i in 1..=5 {
|
||||||
|
let result = env.run_ldr(&["add", &format!("Subtask {}", i), "--under", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls", "--all"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Verify letter sequence (in order added: a, b, c, d, e)
|
||||||
|
assert!(result.stdout.contains("a. Subtask 1"));
|
||||||
|
assert!(result.stdout.contains("b. Subtask 2"));
|
||||||
|
assert!(result.stdout.contains("c. Subtask 3"));
|
||||||
|
assert!(result.stdout.contains("d. Subtask 4"));
|
||||||
|
assert!(result.stdout.contains("e. Subtask 5"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod prioritization_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prioritize_basic() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add tasks
|
||||||
|
env.run_ldr(&["add", "Task A"]);
|
||||||
|
env.run_ldr(&["add", "Task B"]);
|
||||||
|
env.run_ldr(&["add", "Task C"]);
|
||||||
|
|
||||||
|
// Prioritize task 3 (Task A)
|
||||||
|
let result = env.run_ldr(&["up", "3"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("✓ Prioritized 1 task(s)"));
|
||||||
|
|
||||||
|
// Verify new order
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
let lines: Vec<&str> = result.stdout.lines().collect();
|
||||||
|
assert!(lines[0].contains("1. Task A")); // Moved to top
|
||||||
|
assert!(lines[1].contains("2. Task C"));
|
||||||
|
assert!(lines[2].contains("3. Task B"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prioritize_multiple() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add tasks
|
||||||
|
env.run_ldr(&["add", "Task A"]);
|
||||||
|
env.run_ldr(&["add", "Task B"]);
|
||||||
|
env.run_ldr(&["add", "Task C"]);
|
||||||
|
env.run_ldr(&["add", "Task D"]);
|
||||||
|
|
||||||
|
// Prioritize tasks 4, 2 (in that order)
|
||||||
|
let result = env.run_ldr(&["up", "4", "2"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Verify command-line order preserved (POLS)
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
let lines: Vec<&str> = result.stdout.lines().collect();
|
||||||
|
assert!(lines[0].contains("1. Task A")); // 4th item moved to top
|
||||||
|
assert!(lines[1].contains("2. Task C")); // 2nd item moved to second
|
||||||
|
assert!(lines[2].contains("3. Task D")); // Remaining items
|
||||||
|
assert!(lines[3].contains("4. Task B"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prioritize_subtask_moves_parent() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add tasks with subtasks
|
||||||
|
env.run_ldr(&["add", "Task A"]);
|
||||||
|
env.run_ldr(&["add", "Subtask A1", "--under", "1"]);
|
||||||
|
env.run_ldr(&["add", "Task B"]);
|
||||||
|
env.run_ldr(&["add", "Subtask B1", "--under", "2"]);
|
||||||
|
|
||||||
|
// Prioritize subtask - should move entire parent task
|
||||||
|
let result = env.run_ldr(&["up", "2a"]); // Task A is now at position 2
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Verify parent task moved (POLS: subtask reference affects parent)
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("1. Task A")); // Parent moved to top
|
||||||
|
assert!(result.stdout.contains("a. Subtask A1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prioritize_invalid_reference() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Task A"]);
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["up", "5"]);
|
||||||
|
result.assert_failure();
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["up", "1z"]); // Invalid subtask
|
||||||
|
result.assert_failure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod archiving_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_archive_single_task() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Task to complete"]);
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["do", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("✓ Archived 1 item(s)"));
|
||||||
|
|
||||||
|
// Verify task removed from todos
|
||||||
|
let todos = env.read_todos();
|
||||||
|
assert!(!todos.contains("Task to complete"));
|
||||||
|
|
||||||
|
// Verify task in archive
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("Task to complete"));
|
||||||
|
assert!(archive.contains(&chrono::Local::now().format("%Y-%m-%d").to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_archive_subtask() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Main task"]);
|
||||||
|
env.run_ldr(&["add", "Subtask A", "--under", "1"]);
|
||||||
|
env.run_ldr(&["add", "Subtask B", "--under", "1"]);
|
||||||
|
|
||||||
|
// Archive one subtask
|
||||||
|
let result = env.run_ldr(&["do", "1a"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Main task should still exist with remaining subtask
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("Main task"));
|
||||||
|
assert!(result.stdout.contains("Subtask B"));
|
||||||
|
assert!(!result.stdout.contains("Subtask A"));
|
||||||
|
|
||||||
|
// Archive should contain the subtask
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("Subtask A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_complete_parent_when_all_subtasks_done() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Main task"]);
|
||||||
|
env.run_ldr(&["add", "Subtask A", "--under", "1"]);
|
||||||
|
env.run_ldr(&["add", "Subtask B", "--under", "1"]);
|
||||||
|
|
||||||
|
// Archive all subtasks
|
||||||
|
let result = env.run_ldr(&["do", "1a", "1b"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Main task should be auto-completed (POLS: completing all subtasks completes parent)
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(!result.stdout.contains("Main task"));
|
||||||
|
|
||||||
|
// Archive should contain subtasks AND parent task
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("Subtask A"));
|
||||||
|
assert!(archive.contains("Subtask B"));
|
||||||
|
assert!(archive.contains("Main task"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_archive_whole_task_with_subtasks() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Complete project"]);
|
||||||
|
env.run_ldr(&["add", "Write code", "--under", "1"]);
|
||||||
|
env.run_ldr(&["add", "Write tests", "--under", "1"]);
|
||||||
|
|
||||||
|
// Archive entire task (should include all subtasks)
|
||||||
|
let result = env.run_ldr(&["do", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Nothing should remain in todos
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("No notes yet"));
|
||||||
|
|
||||||
|
// Archive should contain complete task structure
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("Complete project"));
|
||||||
|
assert!(archive.contains("Write code"));
|
||||||
|
assert!(archive.contains("Write tests"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod removal_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_vs_archive() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Task to remove"]);
|
||||||
|
env.run_ldr(&["add", "Task to archive"]);
|
||||||
|
|
||||||
|
// Remove one task
|
||||||
|
let result = env.run_ldr(&["rm", "2"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Archive another task
|
||||||
|
let result = env.run_ldr(&["do", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Only archived task should be in archive
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("Task to archive"));
|
||||||
|
assert!(!archive.contains("Task to remove"));
|
||||||
|
|
||||||
|
// Both should be gone from todos
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("No notes yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_subtask() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Main task"]);
|
||||||
|
env.run_ldr(&["add", "Keep this", "--under", "1"]);
|
||||||
|
env.run_ldr(&["add", "Remove this", "--under", "1"]);
|
||||||
|
|
||||||
|
// Remove second subtask (1b)
|
||||||
|
let result = env.run_ldr(&["rm", "1b"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Main task should remain with first subtask only
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("Main task"));
|
||||||
|
assert!(result.stdout.contains("Keep this"));
|
||||||
|
assert!(!result.stdout.contains("Remove this"));
|
||||||
|
|
||||||
|
// Archive should be empty
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(!archive.contains("Remove this"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod filtering_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filtering_basic() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "read: Book about Rust"]);
|
||||||
|
env.run_ldr(&["add", "write: Article about Go"]);
|
||||||
|
env.run_ldr(&["add", "read: Documentation"]);
|
||||||
|
env.run_ldr(&["add", "@work: Review PR"]);
|
||||||
|
|
||||||
|
// Filter by "read:"
|
||||||
|
let result = env.run_ldr(&["ls", "read:"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("read: Book about Rust"));
|
||||||
|
assert!(result.stdout.contains("read: Documentation"));
|
||||||
|
assert!(!result.stdout.contains("write: Article"));
|
||||||
|
assert!(!result.stdout.contains("@work: Review"));
|
||||||
|
|
||||||
|
// Filter by "@work"
|
||||||
|
let result = env.run_ldr(&["ls", "@work"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("@work: Review PR"));
|
||||||
|
assert!(!result.stdout.contains("read:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filtering_case_insensitive() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "READ: Book"]);
|
||||||
|
env.run_ldr(&["add", "read: Article"]);
|
||||||
|
env.run_ldr(&["add", "ReAd: Mixed"]);
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls", "read:"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("READ: Book"));
|
||||||
|
assert!(result.stdout.contains("read: Article"));
|
||||||
|
assert!(result.stdout.contains("ReAd: Mixed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filtering_with_limits() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Add many matching items
|
||||||
|
for i in 1..=10 {
|
||||||
|
env.run_ldr(&["add", &format!("read: Book {}", i)]);
|
||||||
|
}
|
||||||
|
env.run_ldr(&["add", "write: Article"]);
|
||||||
|
|
||||||
|
// Test filtering respects limits
|
||||||
|
let result = env.run_ldr(&["ls", "-n", "3", "read:"]);
|
||||||
|
result.assert_success();
|
||||||
|
let matching_lines: Vec<&str> = result
|
||||||
|
.stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.contains("read:"))
|
||||||
|
.collect();
|
||||||
|
assert!(matching_lines.len() <= 3);
|
||||||
|
|
||||||
|
// Test filtering with --all
|
||||||
|
let result = env.run_ldr(&["ls", "--all", "read:"]);
|
||||||
|
result.assert_success();
|
||||||
|
let matching_lines: Vec<&str> = result
|
||||||
|
.stdout
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.contains("read:"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(matching_lines.len(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filtering_no_matches() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Task A"]);
|
||||||
|
env.run_ldr(&["add", "Task B"]);
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls", "nonexistent"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result
|
||||||
|
.stdout
|
||||||
|
.contains("No items found matching filter: \"nonexistent\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod error_handling {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_task_references() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Task A"]);
|
||||||
|
|
||||||
|
// Test various invalid references
|
||||||
|
let invalid_refs = vec!["0", "99", "1z", "a1", "1ab", "1-2", ""];
|
||||||
|
|
||||||
|
for invalid_ref in invalid_refs {
|
||||||
|
if !invalid_ref.is_empty() {
|
||||||
|
let result = env.run_ldr(&["up", invalid_ref]);
|
||||||
|
result.assert_failure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_operations_on_empty_file() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Try operations on empty file
|
||||||
|
let result = env.run_ldr(&["up", "1"]);
|
||||||
|
result.assert_success(); // Should handle gracefully
|
||||||
|
assert!(result.stdout.contains("No notes found"));
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["do", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("No notes found"));
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["rm", "1"]);
|
||||||
|
result.assert_success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtask_references_without_parent() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Task without subtasks"]);
|
||||||
|
|
||||||
|
// Try to reference non-existent subtask
|
||||||
|
let result = env.run_ldr(&["up", "1a"]);
|
||||||
|
result.assert_failure();
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["do", "1a"]);
|
||||||
|
result.assert_failure();
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["rm", "1a"]);
|
||||||
|
result.assert_failure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod edit_functionality {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_creates_file_if_not_exists() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Set EDITOR to a command that just touches the file and exits
|
||||||
|
let result = Command::new(&env.binary_path)
|
||||||
|
.args(&["edit"])
|
||||||
|
.env("XDG_DATA_HOME", &env.data_dir)
|
||||||
|
.env("EDITOR", "touch") // Will just touch the file
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute edit command");
|
||||||
|
|
||||||
|
// Should succeed (touch command succeeds)
|
||||||
|
assert_eq!(result.status.code().unwrap_or(-1), 0);
|
||||||
|
|
||||||
|
// File should exist with proper structure
|
||||||
|
let todos = env.read_todos();
|
||||||
|
assert!(todos.contains("# TODOs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_aliases() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Test all aliases work by using echo to verify they're called
|
||||||
|
let aliases = vec!["edit", "e", "scan", "s", "review", "r"];
|
||||||
|
|
||||||
|
for alias in aliases {
|
||||||
|
let result = Command::new(&env.binary_path)
|
||||||
|
.args(&[alias])
|
||||||
|
.env("XDG_DATA_HOME", &env.data_dir)
|
||||||
|
.env("EDITOR", "/bin/echo")
|
||||||
|
.output()
|
||||||
|
.expect(&format!("Failed to execute {} command", alias));
|
||||||
|
|
||||||
|
// Should succeed and echo the file path
|
||||||
|
assert_eq!(result.status.code().unwrap_or(-1), 0);
|
||||||
|
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("todos.md"),
|
||||||
|
"Alias '{}' didn't work: {}",
|
||||||
|
alias,
|
||||||
|
stdout
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod migration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_from_plain_text() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Create old-style plain text files
|
||||||
|
let old_note_path = env.data_dir.join("ldr/note.txt");
|
||||||
|
let old_archive_path = env.data_dir.join("ldr/archive.txt");
|
||||||
|
|
||||||
|
fs::create_dir_all(old_note_path.parent().unwrap()).unwrap();
|
||||||
|
|
||||||
|
// Write old-style content
|
||||||
|
fs::write(&old_note_path, "Task A\nTask B with details\nTask C\n").unwrap();
|
||||||
|
fs::write(&old_archive_path, "Completed task 1\nCompleted task 2\n").unwrap();
|
||||||
|
|
||||||
|
// Run any command to trigger migration
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Check that markdown files were created
|
||||||
|
assert!(env.todos_path().exists());
|
||||||
|
assert!(env.archive_path().exists());
|
||||||
|
|
||||||
|
// Verify content was migrated correctly
|
||||||
|
let todos = env.read_todos();
|
||||||
|
assert!(todos.contains("# TODOs"));
|
||||||
|
assert!(todos.contains("- Task A"));
|
||||||
|
assert!(todos.contains("- Task B with details"));
|
||||||
|
assert!(todos.contains("- Task C"));
|
||||||
|
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("# Archive"));
|
||||||
|
assert!(archive.contains("- Completed task 1"));
|
||||||
|
assert!(archive.contains("- Completed task 2"));
|
||||||
|
|
||||||
|
// Verify old files were backed up (not deleted)
|
||||||
|
assert!(old_note_path.with_extension("txt.bak").exists());
|
||||||
|
assert!(old_archive_path.with_extension("txt.bak").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_migration_when_markdown_exists() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
// Create markdown file first
|
||||||
|
fs::create_dir_all(env.todos_path().parent().unwrap()).unwrap();
|
||||||
|
fs::write(&env.todos_path(), "# TODOs\n\n- Existing task\n").unwrap();
|
||||||
|
|
||||||
|
// Create old-style file
|
||||||
|
let old_note_path = env.data_dir.join("ldr/note.txt");
|
||||||
|
fs::write(&old_note_path, "Old task\n").unwrap();
|
||||||
|
|
||||||
|
// Run command
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
// Verify markdown file unchanged (no migration happened)
|
||||||
|
let todos = env.read_todos();
|
||||||
|
assert!(todos.contains("Existing task"));
|
||||||
|
assert!(!todos.contains("Old task"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pols_compliance {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_newest_items_at_top() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Old task"]);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
env.run_ldr(&["add", "New task"]);
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
|
||||||
|
assert!(result.stdout.contains("1. New task"));
|
||||||
|
assert!(result.stdout.contains("2. Old task"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prioritization_order_predictable() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "A"]);
|
||||||
|
env.run_ldr(&["add", "B"]);
|
||||||
|
env.run_ldr(&["add", "C"]);
|
||||||
|
env.run_ldr(&["add", "D"]);
|
||||||
|
|
||||||
|
// Test prioritization is predictable
|
||||||
|
env.run_ldr(&["up", "4", "2"]); // Move A (4th) and C (2nd) to top
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("1. A"));
|
||||||
|
assert!(result.stdout.contains("2. C"));
|
||||||
|
assert!(result.stdout.contains("3. D"));
|
||||||
|
assert!(result.stdout.contains("4. B"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtask_operations_affect_parent_logically() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Project A"]);
|
||||||
|
env.run_ldr(&["add", "Task 1", "--under", "1"]);
|
||||||
|
env.run_ldr(&["add", "Project B"]);
|
||||||
|
|
||||||
|
// Prioritizing subtask should move parent project
|
||||||
|
env.run_ldr(&["up", "2a"]); // Project A is now at position 2
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
// Project A should be at top (moved because of subtask reference)
|
||||||
|
assert!(result.stdout.contains("1. Project A"));
|
||||||
|
assert!(result.stdout.contains("a. Task 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_completion_cascades_logically() {
|
||||||
|
let env = TestEnv::new();
|
||||||
|
|
||||||
|
env.run_ldr(&["add", "Big project"]);
|
||||||
|
env.run_ldr(&["add", "Only subtask", "--under", "1"]);
|
||||||
|
|
||||||
|
// Completing the only subtask should complete parent
|
||||||
|
env.run_ldr(&["do", "1a"]);
|
||||||
|
|
||||||
|
let result = env.run_ldr(&["ls"]);
|
||||||
|
result.assert_success();
|
||||||
|
assert!(result.stdout.contains("No notes yet"));
|
||||||
|
|
||||||
|
// Both should be in archive
|
||||||
|
let archive = env.read_archive();
|
||||||
|
assert!(archive.contains("Only subtask"));
|
||||||
|
assert!(archive.contains("Big project"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn run_ldr(dir: &TempDir, args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_ldr"))
|
||||||
|
.env("XDG_DATA_HOME", dir.path())
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("Failed to run ldr")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_task_rejected() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Try to add empty task
|
||||||
|
let output = run_ldr(&dir, &["add", ""]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(stderr.contains("Cannot add empty task"));
|
||||||
|
|
||||||
|
// Try to add whitespace-only task
|
||||||
|
let output = run_ldr(&dir, &["add", " "]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_task_length_limit() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a very long task (over 500 chars)
|
||||||
|
let long_text = "x".repeat(501);
|
||||||
|
let output = run_ldr(&dir, &["add", &long_text]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(stderr.contains("Task text too long"));
|
||||||
|
assert!(stderr.contains("Maximum length is 500"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_subtasks_limit() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Add a main task
|
||||||
|
run_ldr(&dir, &["add", "Main task"]);
|
||||||
|
|
||||||
|
// Add 26 subtasks (a-z)
|
||||||
|
for i in 0..26 {
|
||||||
|
let subtask = format!("Subtask {}", i);
|
||||||
|
let output = run_ldr(&dir, &["add", &subtask, "--under", "1"]);
|
||||||
|
assert!(output.status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to add 27th subtask - should fail
|
||||||
|
let output = run_ldr(&dir, &["add", "One too many", "--under", "1"]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(stderr.contains("already has maximum number of subtasks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_task_references() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Add some tasks
|
||||||
|
run_ldr(&dir, &["add", "Task 1"]);
|
||||||
|
run_ldr(&dir, &["add", "Task 2"]);
|
||||||
|
|
||||||
|
// Test zero task number - should print error but return success
|
||||||
|
let output = run_ldr(&dir, &["up", "0"]);
|
||||||
|
// Check that error message was printed even if exit code is 0
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("Invalid task reference")
|
||||||
|
|| stdout.contains("Task number must be at least 1")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test too large task number
|
||||||
|
let output = run_ldr(&dir, &["up", "999"]);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("Invalid task number"));
|
||||||
|
|
||||||
|
// Test invalid formats
|
||||||
|
let output = run_ldr(&dir, &["up", "1A"]); // uppercase letter
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("Invalid") || stdout.contains("uppercase"));
|
||||||
|
|
||||||
|
let output = run_ldr(&dir, &["up", "abc"]); // no number
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("Invalid") || stdout.contains("must start with a number"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtask_under_invalid_parent() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Try to add subtask under non-existent task
|
||||||
|
let output = run_ldr(&dir, &["add", "Subtask", "--under", "1"]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(stderr.contains("Invalid task number"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deep_nesting_prevention() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a markdown file with deep nesting
|
||||||
|
// ldr stores files in a subdirectory
|
||||||
|
std::fs::create_dir_all(dir.path().join("ldr")).unwrap();
|
||||||
|
let todos_path = dir.path().join("ldr").join("todos.md");
|
||||||
|
let content = r#"# TODOs
|
||||||
|
|
||||||
|
- Task 1
|
||||||
|
- Subtask 1
|
||||||
|
- Deep subtask (should be flattened)
|
||||||
|
- Very deep subtask (should be flattened)
|
||||||
|
"#;
|
||||||
|
std::fs::write(&todos_path, content).unwrap();
|
||||||
|
|
||||||
|
// List tasks - deep nesting should be handled silently now
|
||||||
|
// (warnings are printed during parse but list should work)
|
||||||
|
let output = run_ldr(&dir, &["ls", "-a"]);
|
||||||
|
assert!(output.status.success());
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
// The deep subtasks should appear as regular subtasks (flattened)
|
||||||
|
// We should see the subtasks in the output
|
||||||
|
assert!(stdout.contains("Subtask 1") || stdout.contains("a."));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user