From 4d4118f4ce20a06e323a4f794b1d9caa58573413 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Fri, 24 Apr 2026 20:06:32 +0200 Subject: [PATCH] feat: todo done accepts multiple task numbers and prints completed task - Change `todo done ` to variadic `todo done ...` for stable indices across sequential calls - Process multiple numbers highest-index-first so lower indices remain valid as tasks are removed - Validate all numbers upfront before mutating any files - After marking done, print the full task block (same format as list) so the user gets visual confirmation of what was completed - Extract `mark_task_done` as a testable helper; add unit tests --- src/cli/args.rs | 7 +-- src/cli/commands/todo.rs | 112 ++++++++++++++++++++++++++++++++++----- src/main.rs | 2 +- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index 5666337..af1303d 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -20,10 +20,11 @@ pub enum TodoAction { /// Task number to edit number: usize, }, - /// Mark a task as done + /// Mark one or more tasks as done Done { - /// Task number to mark as done - number: usize, + /// Task numbers to mark as done (processed highest-index-first for stable indices) + #[arg(required = true, num_args = 1..)] + numbers: Vec, }, } diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index 66176cf..ca38fbd 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -95,15 +95,12 @@ pub fn run_edit(number: usize) -> Result<(), StreamdError> { Ok(()) } -pub fn run_done(number: usize) -> Result<(), StreamdError> { - // Always include all tasks for done (user might want to mark a future task as done) - let tasks = collect_open_tasks(true)?; - - if number == 0 || number > tasks.len() { - return Err(StreamdError::InvalidTaskNumber(number, tasks.len())); +pub fn mark_task_done(task_number: usize, tasks: &[LocalizedShard]) -> Result<(), StreamdError> { + if task_number == 0 || task_number > tasks.len() { + return Err(StreamdError::InvalidTaskNumber(task_number, tasks.len())); } - let task = &tasks[number - 1]; + let task = &tasks[task_number - 1]; let file_path = task .location .get("file") @@ -112,7 +109,6 @@ pub fn run_done(number: usize) -> Result<(), StreamdError> { let content = fs::read_to_string(file_path)?; let mut lines: Vec = content.lines().map(String::from).collect(); - // Find the line containing @Task (should be at start_line) let task_line_idx = task.start_line.saturating_sub(1); if task_line_idx >= lines.len() { return Err(StreamdError::InvalidLineNumber); @@ -120,7 +116,6 @@ pub fn run_done(number: usize) -> Result<(), StreamdError> { let line = &lines[task_line_idx]; - // Check for multiple @Task occurrences let task_count = line.matches("@Task").count(); if task_count > 1 { return Err(StreamdError::MultipleTaskMarkers( @@ -135,11 +130,9 @@ pub fn run_done(number: usize) -> Result<(), StreamdError> { )); } - // Insert @Done after @Task let new_line = line.replacen("@Task", "@Task @Done", 1); - lines[task_line_idx] = new_line; + lines[task_line_idx] = new_line.clone(); - // Write back to file, preserving trailing newline if present let new_content = if content.ends_with('\n') { format!("{}\n", lines.join("\n")) } else { @@ -147,7 +140,39 @@ pub fn run_done(number: usize) -> Result<(), StreamdError> { }; fs::write(file_path, new_content)?; - println!("Marked task {} as done", number); + // Print the completed task block + let start = task.start_line.saturating_sub(1); + let end = std::cmp::min(task.end_line, lines.len()); + println!( + "Done: [{}] --- {}:{} ---", + task_number, file_path, task.start_line + ); + for line in &lines[start..end] { + println!("{}", line); + } + println!(); + + Ok(()) +} + +pub fn run_done(numbers: &[usize]) -> Result<(), StreamdError> { + let tasks = collect_open_tasks(true)?; + + // Validate all numbers before processing any + for &number in numbers { + if number == 0 || number > tasks.len() { + return Err(StreamdError::InvalidTaskNumber(number, tasks.len())); + } + } + + // Process highest-index-first so earlier indices remain valid + let mut sorted_numbers: Vec = numbers.to_vec(); + sorted_numbers.sort_unstable_by(|a, b| b.cmp(a)); + sorted_numbers.dedup(); + + for number in sorted_numbers { + mark_task_done(number, &tasks)?; + } Ok(()) } @@ -316,4 +341,65 @@ mod tests { "No @Task marker found in /path/file.md:42" ); } + + #[test] + fn test_done_numbers_sorted_highest_first() { + let mut numbers: Vec = vec![1, 3, 2]; + numbers.sort_unstable_by(|a, b| b.cmp(a)); + assert_eq!(numbers, vec![3, 2, 1]); + } + + #[test] + fn test_done_numbers_deduped() { + let mut numbers: Vec = vec![3, 2, 3, 1]; + numbers.sort_unstable_by(|a, b| b.cmp(a)); + numbers.dedup(); + assert_eq!(numbers, vec![3, 2, 1]); + } + + #[test] + fn test_mark_task_done_writes_file_and_prints() { + use std::io::Write; + use tempfile::NamedTempFile; + + let mut tmp = NamedTempFile::new().unwrap(); + writeln!(tmp, "## Fix the thing @Task").unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + + let now = Utc::now(); + let mut location = IndexMap::new(); + location.insert("file".to_string(), path.clone()); + location.insert("task".to_string(), "open".to_string()); + + let task = LocalizedShard { + markers: vec!["Task".to_string()], + tags: vec![], + start_line: 1, + end_line: 1, + moment: now, + location, + children: vec![], + }; + + let tasks = vec![task]; + mark_task_done(1, &tasks).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert!(result.contains("@Task @Done")); + } + + #[test] + fn test_mark_task_done_invalid_number_zero() { + let tasks = vec![]; + let err = mark_task_done(0, &tasks).unwrap_err(); + assert!(matches!(err, StreamdError::InvalidTaskNumber(0, 0))); + } + + #[test] + fn test_mark_task_done_invalid_number_exceeds() { + let now = Utc::now(); + let tasks = vec![make_task_shard(now, "a.md")]; + let err = mark_task_done(2, &tasks).unwrap_err(); + assert!(matches!(err, StreamdError::InvalidTaskNumber(2, 1))); + } } diff --git a/src/main.rs b/src/main.rs index d543ea5..e7fc178 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ fn main() -> miette::Result<()> { }) => match action { None => streamd::cli::commands::todo::run_list(show_future)?, Some(TodoAction::Edit { number }) => streamd::cli::commands::todo::run_edit(number)?, - Some(TodoAction::Done { number }) => streamd::cli::commands::todo::run_done(number)?, + Some(TodoAction::Done { numbers }) => streamd::cli::commands::todo::run_done(&numbers)?, }, Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, Some(Commands::Timesheet { decimal, debug }) => {