diff --git a/README.md b/README.md index 1ee95a9..e2e0bcd 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,7 @@ 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), 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 todo` — Show all open tasks (shards with `@Task` markers) - `streamd edit [number]` — Edit a stream file by index (most recent first) - `streamd timesheet` — Generate time reports from `@Timesheet` markers @@ -63,8 +60,4 @@ 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 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` +Running `streamd todo` finds all shards marked as open tasks and displays them as rich-formatted panels in your terminal. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 49f3f42..04e8898 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -387,40 +387,11 @@ 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"`, 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 todo` | List all shards with `task: "open"` | | `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 diff --git a/src/cli/args.rs b/src/cli/args.rs index be56ee3..cb2c504 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -13,34 +13,13 @@ 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 { - /// Show tasks with dates in the future - #[arg(long)] - show_future: bool, - - #[command(subcommand)] - action: Option, - }, + Todo, /// Edit a stream file by position Edit { diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index fe8e870..ee5a422 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -1,7 +1,5 @@ use std::fs; -use std::process::Command; -use chrono::Utc; use walkdir::WalkDir; use crate::config::Settings; @@ -35,26 +33,10 @@ fn all_files() -> Result, StreamdError> { Ok(shards) } -pub fn collect_open_tasks(show_future: bool) -> Result, StreamdError> { +pub fn run() -> Result<(), StreamdError> { let all_shards = all_files()?; - let now = Utc::now(); - 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 + for task_shard in find_shard_by_position(&all_shards, "task", "open") { 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(); @@ -62,10 +44,7 @@ pub fn run_list(show_future: bool) -> Result<(), StreamdError> { let start = task_shard.start_line.saturating_sub(1); let end = std::cmp::min(task_shard.end_line, lines.len()); - println!( - "[{}] --- {}:{} ---", - task_number, file_path, task_shard.start_line - ); + println!("--- {}:{} ---", file_path, task_shard.start_line); for line in &lines[start..end] { println!("{}", line); } @@ -75,257 +54,3 @@ pub fn run_list(show_future: bool) -> 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 { - 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"; - 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_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"; - 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); - } - - #[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 = [ - 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" - ); - } -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7642ab0..6fdae80 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, TodoAction}; +pub use args::{Cli, Commands}; diff --git a/src/error.rs b/src/error.rs index 2512ef9..c76801c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,21 +16,6 @@ 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 e6325c8..d4bfd9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,12 @@ use clap::Parser; -use streamd::cli::{Cli, Commands, TodoAction}; +use streamd::cli::{Cli, Commands}; fn main() -> miette::Result<()> { let cli = Cli::parse(); match cli.command { Some(Commands::New) => streamd::cli::commands::new::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::Todo) => streamd::cli::commands::todo::run()?, Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?, Some(Commands::Completions { shell }) => {