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 }) => {