Compare commits

...
12 Commits
Author SHA1 Message Date
swaits 3ffff592b1 chore: repoint URLs at git.swaits.com and bump for release
Rewrites remaining github.com / git.sr.ht references to git.swaits.com,
translating GitHub URL shapes to Gitea's (/blob/<ref>/ -> /src/branch/<ref>/,
/tree/<tag> -> /src/tag/<tag>, /actions/workflows/X -> /actions?workflow=X,
/edit/ -> /_edit/).

Left pointing at GitHub on purpose: pull-request links (PRs were not migrated)
and GitHub-only surfaces Gitea lacks (security advisories, discussions).
2026-08-31 17:58:34 -06:00
swaits cdaa62682a chore: point repository at git.swaits.com 2026-08-31 17:19:20 -06:00
swaits 5888ca8982 bump version 2025-08-24 10:02:31 -06:00
swaits 6068a0ed9f refactoring, cleanup 2025-08-24 09:48:23 -06:00
swaits a482d96b29 update crates 2025-08-19 17:28:23 -06:00
swaits a714bbab85 remove multilist support 2025-08-19 15:19:37 -06:00
swaits 4b7569b844 make scan/s/r/review aliases for edit subcommand 2025-08-19 15:01:26 -06:00
swaits 8b736c1d22 create integration tests 2025-08-19 14:57:30 -06:00
swaits 99232887c5 change ls formatting 2025-08-19 14:46:40 -06:00
swaits d658da2e99 remove scan subcommand, rely solely on edit now 2025-08-19 14:28:14 -06:00
swaits e6bb65d37b move to Markdown for data and support subtasks 2025-08-19 14:21:23 -06:00
swaits d84cdc55ab initialize Claude 2025-08-19 13:54:14 -06:00
12 changed files with 3036 additions and 538 deletions

No files matched your search

+64
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+16 -11
View File
@@ -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],
+1
View File
@@ -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
View File
@@ -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(&note_path, &archive_path, &todo_md_path, &archive_md_path) {
migration::perform_migration(&note_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(&note_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(&note_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(&note_path, &numbers), Commands::Up { refs } => commands::prioritize_items(&todo_md_path, &refs)
Commands::Do { numbers } => commands::archive_items(&note_path, &archive_path, &numbers), .context("Failed to prioritize items")?,
Commands::Rm { numbers } => commands::remove_items(&note_path, &numbers), Commands::Do { refs } => commands::archive_items(&todo_md_path, &archive_md_path, &refs)
Commands::Scan => commands::review_note(&note_path, &archive_path), .context("Failed to archive items")?,
Commands::Edit => commands::edit_note(&note_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
View File
@@ -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
}
}
+245
View File
@@ -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(
&note_path,
&archive_path,
&todo_md_path,
&archive_md_path
));
// Create old file
fs::write(&note_path, "test").unwrap();
// Old file exists, no new files - migration needed
assert!(needs_migration(
&note_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(
&note_path,
&archive_path,
&todo_md_path,
&archive_md_path
));
}
}
+133 -40
View File
@@ -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"));
} }
} }
+831
View File
@@ -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"));
}
}
+130
View File
@@ -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."));
}