use std::fs; use std::path::Path; use std::process::Command; use chrono::Utc; use crate::config::Settings; use crate::error::StreamdError; use crate::localize::TaskConfiguration; use crate::models::LocalizedShard; use crate::query::find_shard_by_position; use super::load_markdown_shards; pub fn collect_open_tasks(show_future: bool) -> Result, StreamdError> { let settings = Settings::load()?; let all_shards = load_markdown_shards( Path::new(&settings.base_folder), &TaskConfiguration, chrono_tz::UTC, )?; 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 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(); 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 ); for line in &lines[start..end] { println!("{}", line); } println!(); } } 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" ); } }