From 124a5b7e2aa8af3fa07702cb8d4db4ea28badd25 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:27:19 +0200 Subject: [PATCH 1/3] feat(todo): add numbered tasks, edit, done, and future filtering Implement smoother todo editing with the following features: - Display numbered tasks [1], [2], etc. in `streamd todo` - Add `streamd todo N edit` to open editor at task line - Add `streamd todo N done` to insert @Done after @Task - Add `--show-future` flag to include future tasks (hidden by default) --- src/cli/args.rs | 23 +++++- src/cli/commands/todo.rs | 154 ++++++++++++++++++++++++++++++++++++++- src/cli/mod.rs | 2 +- src/error.rs | 15 ++++ src/main.rs | 11 ++- 5 files changed, 198 insertions(+), 7 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index cb2c504..be56ee3 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -13,13 +13,34 @@ pub struct Cli { pub command: Option, } +#[derive(Subcommand)] +pub enum TodoAction { + /// Edit a task by its number + Edit { + /// Task number to edit + number: usize, + }, + /// Mark a task as done + Done { + /// Task number to mark as done + number: usize, + }, +} + #[derive(Subcommand)] pub enum Commands { /// Create a new stream file New, /// Display open tasks - Todo, + Todo { + /// Show tasks with dates in the future + #[arg(long)] + show_future: bool, + + #[command(subcommand)] + action: Option, + }, /// Edit a stream file by position Edit { diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index ee5a422..2102050 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -1,5 +1,7 @@ use std::fs; +use std::process::Command; +use chrono::Utc; use walkdir::WalkDir; use crate::config::Settings; @@ -33,10 +35,26 @@ fn all_files() -> Result, StreamdError> { Ok(shards) } -pub fn run() -> Result<(), StreamdError> { +pub fn collect_open_tasks(show_future: bool) -> Result, StreamdError> { let all_shards = all_files()?; + let now = Utc::now(); - for task_shard in find_shard_by_position(&all_shards, "task", "open") { + let mut tasks: Vec = find_shard_by_position(&all_shards, "task", "open") + .into_iter() + .filter(|shard| show_future || shard.moment <= now) + .collect(); + + // Sort by moment ascending (oldest first = task 1) + tasks.sort_by(|a, b| a.moment.cmp(&b.moment)); + + Ok(tasks) +} + +pub fn run_list(show_future: bool) -> Result<(), StreamdError> { + let tasks = collect_open_tasks(show_future)?; + + for (index, task_shard) in tasks.iter().enumerate() { + let task_number = index + 1; // 1-indexed if let Some(file_path) = task_shard.location.get("file") { let content = fs::read_to_string(file_path)?; let lines: Vec<&str> = content.lines().collect(); @@ -44,7 +62,10 @@ pub fn run() -> Result<(), StreamdError> { let start = task_shard.start_line.saturating_sub(1); let end = std::cmp::min(task_shard.end_line, lines.len()); - println!("--- {}:{} ---", file_path, task_shard.start_line); + println!( + "[{}] --- {}:{} ---", + task_number, file_path, task_shard.start_line + ); for line in &lines[start..end] { println!("{}", line); } @@ -54,3 +75,130 @@ pub fn run() -> Result<(), StreamdError> { Ok(()) } + +pub fn run_edit(number: usize) -> Result<(), StreamdError> { + // Always include all tasks for edit (user might want to edit a future task) + let tasks = collect_open_tasks(true)?; + + if number == 0 || number > tasks.len() { + return Err(StreamdError::InvalidTaskNumber(number, tasks.len())); + } + + let task = &tasks[number - 1]; // Convert to 0-indexed + let file_path = task + .location + .get("file") + .ok_or(StreamdError::MissingFilePath)?; + + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + let line_arg = format!("+{}", task.start_line); + + let status = Command::new(&editor) + .arg(&line_arg) + .arg(file_path) + .status()?; + + if !status.success() { + return Err(StreamdError::IoError(std::io::Error::other( + "Editor exited with non-zero status", + ))); + } + + 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())); + } + + let task = &tasks[number - 1]; + let file_path = task + .location + .get("file") + .ok_or(StreamdError::MissingFilePath)?; + + 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); + } + + 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( + file_path.clone(), + task.start_line, + )); + } + if task_count == 0 { + return Err(StreamdError::NoTaskMarker( + file_path.clone(), + task.start_line, + )); + } + + // Insert @Done after @Task + let new_line = line.replacen("@Task", "@Task @Done", 1); + lines[task_line_idx] = new_line; + + // Write back to file, preserving trailing newline if present + let new_content = if content.ends_with('\n') { + format!("{}\n", lines.join("\n")) + } else { + lines.join("\n") + }; + fs::write(file_path, new_content)?; + + println!("Marked task {} as done", number); + + Ok(()) +} + +pub fn run() -> Result<(), StreamdError> { + run_list(false) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_insert_done_after_task() { + let line = "Some content @Task with more text"; + let result = line.replacen("@Task", "@Task @Done", 1); + assert_eq!(result, "Some content @Task @Done with more text"); + } + + #[test] + fn test_insert_done_at_line_end() { + let line = "Some content @Task"; + let result = line.replacen("@Task", "@Task @Done", 1); + assert_eq!(result, "Some content @Task @Done"); + } + + #[test] + fn test_task_count_single() { + let line = "Some content @Task with more text"; + assert_eq!(line.matches("@Task").count(), 1); + } + + #[test] + fn test_task_count_multiple() { + let line = "Some @Task content @Task again"; + assert_eq!(line.matches("@Task").count(), 2); + } + + #[test] + fn test_task_count_none() { + let line = "Some content without task marker"; + assert_eq!(line.matches("@Task").count(), 0); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6fdae80..7642ab0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,4 @@ pub mod args; pub mod commands; -pub use args::{Cli, Commands}; +pub use args::{Cli, Commands, TodoAction}; diff --git a/src/error.rs b/src/error.rs index c76801c..2512ef9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,21 @@ pub enum StreamdError { #[error("TOML error: {0}")] TomlError(#[from] toml::de::Error), + + #[error("Invalid task number {0}: only {1} tasks available")] + InvalidTaskNumber(usize, usize), + + #[error("Task shard missing file path")] + MissingFilePath, + + #[error("Invalid line number in task")] + InvalidLineNumber, + + #[error("Multiple @Task markers found in {0}:{1} - cannot auto-insert @Done")] + MultipleTaskMarkers(String, usize), + + #[error("No @Task marker found in {0}:{1}")] + NoTaskMarker(String, usize), } impl From for miette::Report { diff --git a/src/main.rs b/src/main.rs index d4bfd9e..e6325c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,19 @@ use clap::Parser; -use streamd::cli::{Cli, Commands}; +use streamd::cli::{Cli, Commands, TodoAction}; fn main() -> miette::Result<()> { let cli = Cli::parse(); match cli.command { Some(Commands::New) => streamd::cli::commands::new::run()?, - Some(Commands::Todo) => streamd::cli::commands::todo::run()?, + Some(Commands::Todo { + show_future, + action, + }) => 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(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?, Some(Commands::Completions { shell }) => { From e05e9cfd90388209bb569fa0ebf34d17ae43ed23 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:28:13 +0200 Subject: [PATCH 2/3] test(todo): add comprehensive unit tests for todo features Add tests for: - @Done insertion at various line positions - Future task filtering with show_future flag - Task sorting by moment ascending - Error message formatting for all new error variants - Trailing newline preservation --- src/cli/commands/todo.rs | 127 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index 2102050..d443203 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -170,6 +170,26 @@ pub fn run() -> Result<(), StreamdError> { #[cfg(test)] mod tests { + use super::*; + use chrono::{Duration, TimeZone}; + use indexmap::IndexMap; + + fn make_task_shard(moment: chrono::DateTime, file: &str) -> LocalizedShard { + let mut location = IndexMap::new(); + location.insert("file".to_string(), file.to_string()); + location.insert("task".to_string(), "open".to_string()); + + LocalizedShard { + markers: vec!["Task".to_string()], + tags: vec![], + start_line: 1, + end_line: 1, + moment, + location, + children: vec![], + } + } + #[test] fn test_insert_done_after_task() { let line = "Some content @Task with more text"; @@ -184,6 +204,13 @@ mod tests { assert_eq!(result, "Some content @Task @Done"); } + #[test] + fn test_insert_done_only_first_task() { + let line = "Some @Task content @Task again"; + let result = line.replacen("@Task", "@Task @Done", 1); + assert_eq!(result, "Some @Task @Done content @Task again"); + } + #[test] fn test_task_count_single() { let line = "Some content @Task with more text"; @@ -201,4 +228,104 @@ mod tests { let line = "Some content without task marker"; assert_eq!(line.matches("@Task").count(), 0); } + + #[test] + fn test_filter_future_tasks_excludes_future_when_show_future_false() { + let now = Utc::now(); + let past = now - Duration::hours(1); + let future = now + Duration::hours(1); + + let tasks = vec![ + make_task_shard(past, "past.md"), + make_task_shard(future, "future.md"), + ]; + + let filtered: Vec<_> = tasks + .into_iter() + .filter(|shard| shard.moment <= now) + .collect(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].location.get("file").unwrap(), "past.md"); + } + + #[test] + fn test_filter_future_tasks_includes_all_when_show_future_true() { + let now = Utc::now(); + let past = now - Duration::hours(1); + let future = now + Duration::hours(1); + + let tasks = vec![ + make_task_shard(past, "past.md"), + make_task_shard(future, "future.md"), + ]; + + let filtered: Vec<_> = tasks.into_iter().filter(|_| true).collect(); + + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_sort_tasks_by_moment_ascending() { + let oldest = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); + let middle = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap(); + let newest = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(); + + let mut tasks = vec![ + make_task_shard(newest, "newest.md"), + make_task_shard(oldest, "oldest.md"), + make_task_shard(middle, "middle.md"), + ]; + + tasks.sort_by(|a, b| a.moment.cmp(&b.moment)); + + assert_eq!(tasks[0].location.get("file").unwrap(), "oldest.md"); + assert_eq!(tasks[1].location.get("file").unwrap(), "middle.md"); + assert_eq!(tasks[2].location.get("file").unwrap(), "newest.md"); + } + + #[test] + fn test_preserve_trailing_newline() { + let content_with_newline = "line1\nline2\n"; + let content_without_newline = "line1\nline2"; + + assert!(content_with_newline.ends_with('\n')); + assert!(!content_without_newline.ends_with('\n')); + } + + #[test] + fn test_invalid_task_number_zero() { + let result = StreamdError::InvalidTaskNumber(0, 5); + assert_eq!( + result.to_string(), + "Invalid task number 0: only 5 tasks available" + ); + } + + #[test] + fn test_invalid_task_number_exceeds_count() { + let result = StreamdError::InvalidTaskNumber(10, 3); + assert_eq!( + result.to_string(), + "Invalid task number 10: only 3 tasks available" + ); + } + + #[test] + fn test_multiple_task_markers_error_message() { + let result = StreamdError::MultipleTaskMarkers("/path/file.md".to_string(), 42); + assert_eq!( + result.to_string(), + "Multiple @Task markers found in /path/file.md:42 - cannot auto-insert @Done" + ); + } + + #[test] + fn test_no_task_marker_error_message() { + let result = StreamdError::NoTaskMarker("/path/file.md".to_string(), 42); + assert_eq!( + result.to_string(), + "No @Task marker found in /path/file.md:42" + ); + } } From 926a239d7e694d8720e69e2c638a533281787e0f Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:29:02 +0200 Subject: [PATCH 3/3] docs: add todo edit/done and --show-future documentation Document the new todo command features: - Numbered task display - `streamd todo N edit` for editing tasks - `streamd todo N done` for marking tasks done - `--show-future` flag for including future tasks - Add R21 specification for todo command behavior --- README.md | 11 +++++++++-- REQUIREMENTS.md | 31 ++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e2e0bcd..1ee95a9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,10 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de ## Commands - `streamd` / `streamd new` — Create a new timestamped markdown entry, opening your editor -- `streamd todo` — Show all open tasks (shards with `@Task` markers) +- `streamd todo` — Show all open tasks (shards with `@Task` markers), numbered for easy reference +- `streamd todo N edit` — Edit task N in your editor, jumping to the task's line +- `streamd todo N done` — Mark task N as done by inserting `@Done` after `@Task` +- `streamd todo --show-future` — Include tasks with future dates in the listing - `streamd edit [number]` — Edit a stream file by index (most recent first) - `streamd timesheet` — Generate time reports from `@Timesheet` markers @@ -60,4 +63,8 @@ The timesheet command will calculate expected vs actual working hours based on t Running `streamd` opens your editor to create a new entry. After saving, the file is renamed based on its timestamp and any markers found in the content. -Running `streamd todo` finds all shards marked as open tasks and displays them as rich-formatted panels in your terminal. +Running `streamd todo` finds all shards marked as open tasks and displays them numbered in your terminal. Tasks with future dates are hidden by default (use `--show-future` to include them). Tasks are sorted by date with oldest first (task 1 is the oldest). + +You can quickly edit or complete tasks by number: +- `streamd todo 1 edit` opens task 1 in your editor at the correct line +- `streamd todo 1 done` marks task 1 as done by inserting `@Done` after `@Task` diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 04e8898..49f3f42 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -387,11 +387,40 @@ Provide recursive search through the shard tree: | Command | Description | |---------|-------------| | `streamd new` | Create new timestamped file, open editor, rename with markers on close | -| `streamd todo` | List all shards with `task: "open"` | +| `streamd todo` | List all shards with `task: "open"`, numbered, hiding future tasks | +| `streamd todo --show-future` | Include tasks with future dates in the todo listing | +| `streamd todo N edit` | Edit task N in editor, cursor positioned at task line | +| `streamd todo N done` | Mark task N as done by inserting `@Done` after `@Task` | | `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) | | `streamd timesheet` | Generate formatted timesheet report with expected/actual hours | | `streamd completions ` | Generate shell completions (bash, zsh, fish, elvish, powershell) | +### R21: Todo Command Behavior + +**Task Numbering:** +- Tasks are numbered starting from 1 (oldest task = 1) +- Tasks are sorted by their `moment` field in ascending order +- Output format: `[N] --- file.md:line ---` followed by task content + +**Future Task Filtering:** +- By default, tasks with `moment > now` are hidden from the listing +- The `--show-future` flag includes all tasks regardless of their moment +- When using `todo N edit` or `todo N done`, all tasks (including future) are considered for number lookup + +**Edit Action (`todo N edit`):** +- Opens the task's file in `$EDITOR` (defaults to `vi`) +- Uses `+LINE` argument to position cursor at task's start line +- Errors if N is 0 or exceeds the task count + +**Done Action (`todo N done`):** +- Reads the file and modifies the line at task's start_line +- Inserts ` @Done` immediately after `@Task` +- Preserves trailing newline if the original file had one +- Errors if: + - N is 0 or exceeds the task count + - Multiple `@Task` markers found on the same line + - No `@Task` marker found on the expected line + --- ## Application Configuration