1mod cli;
2mod error;
3mod project;
4mod scan;
5mod todo;
6
7use std::collections::HashSet;
8use std::env;
9use std::io::{self, Write};
10
11use cli::Command;
12pub use error::{AppError, Result};
13use project::{find_todo_file, init_todo_file};
14use scan::scan_project;
15use todo::TodoList;
16
17pub fn run() -> Result<()> {
18 let command = cli::parse_args(env::args_os().skip(1))?;
19 execute(command, &mut io::stdout())
20}
21
22fn execute<W: Write>(command: Command, writer: &mut W) -> Result<()> {
23 match command {
24 Command::Help => {
25 writer.write_all(cli::HELP_TEXT.as_bytes())?;
26 }
27 Command::Init => {
28 let cwd = env::current_dir()?;
29 let path = init_todo_file(&cwd)?;
30 writeln!(writer, "Initialized {}", path.display())?;
31 }
32 other => {
33 let cwd = env::current_dir()?;
34 let todo_path = find_todo_file(&cwd)?;
35 let mut todos = TodoList::load(&todo_path)?;
36
37 match other {
38 Command::List => write_task_list(writer, &todo_path, &todos)?,
39 Command::Add(text) => {
40 let index = todos.add(text)?;
41 let task = &todos.tasks()[index - 1];
42 todos.save(&todo_path)?;
43 writeln!(writer, "Added task {index}: {}", task.text)?;
44 }
45 Command::Done(index) => {
46 let task = todos.mark_done(index)?.text.clone();
47 todos.save(&todo_path)?;
48 writeln!(writer, "Completed task {index}: {task}")?;
49 }
50 Command::Uncheck(index) => {
51 let task = todos.mark_undone(index)?.text.clone();
52 todos.save(&todo_path)?;
53 writeln!(writer, "Unchecked task {index}: {task}")?;
54 }
55 Command::Scan => {
56 let project_root = todo_path
57 .parent()
58 .unwrap_or_else(|| std::path::Path::new("."));
59 let scanned_tasks = scan_project(project_root)?;
60 let mut existing = todos
61 .tasks()
62 .iter()
63 .map(|task| task.text.clone())
64 .collect::<HashSet<_>>();
65 let mut added = 0usize;
66
67 for task in scanned_tasks {
68 if existing.insert(task.clone()) {
69 todos.add(task)?;
70 added += 1;
71 }
72 }
73
74 if added == 0 {
75 writeln!(writer, "No new TODO comments found in git-tracked files.")?;
76 } else {
77 todos.save(&todo_path)?;
78 writeln!(
79 writer,
80 "Added {added} task{} from git-tracked TODO comments.",
81 if added == 1 { "" } else { "s" }
82 )?;
83 }
84 }
85 Command::Remove(index) => {
86 let task = todos.remove(index)?;
87 todos.save(&todo_path)?;
88 writeln!(writer, "Removed task {index}: {}", task.text)?;
89 }
90 Command::Next => {
91 if let Some((index, task)) = todos.next_open_task() {
92 writeln!(writer, "Next task: {index}. {}", task.text)?;
93 } else {
94 writeln!(writer, "All tasks are complete.")?;
95 }
96 }
97 Command::Help | Command::Init => unreachable!("handled above"),
98 }
99 }
100 }
101
102 Ok(())
103}
104
105fn write_task_list<W: Write>(
106 writer: &mut W,
107 todo_path: &std::path::Path,
108 todos: &TodoList,
109) -> Result<()> {
110 writeln!(writer, "Tasks from {}", todo_path.display())?;
111
112 if todos.tasks().is_empty() {
113 writeln!(writer, "No tasks yet.")?;
114 return Ok(());
115 }
116
117 for (index, task) in todos.tasks().iter().enumerate() {
118 let marker = if task.done { "[x]" } else { "[ ]" };
119 writeln!(writer, "{}. {} {}", index + 1, marker, task.text)?;
120 }
121
122 writeln!(
123 writer,
124 "Open: {} Done: {}",
125 todos.open_count(),
126 todos.done_count()
127 )?;
128 Ok(())
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn help_command_writes_usage() {
137 let mut output = Vec::new();
138 execute(Command::Help, &mut output).unwrap();
139
140 let rendered = String::from_utf8(output).unwrap();
141 assert!(rendered.contains("to ls"));
142 assert!(rendered.contains("to init"));
143 }
144}