1mod cli;
2mod error;
3mod project;
4mod scan;
5mod todo;
6
7use std::collections::HashSet;
8use std::env;
9use std::io::{self, IsTerminal, Write};
10use std::path::Path;
11use std::process::Command as ProcessCommand;
12
13use cli::Command;
14pub use error::{AppError, Result};
15use project::{find_todo_file, init_todo_file};
16use scan::scan_project;
17use todo::TodoList;
18
19const OPENCODE_AGENT_PROMPT: &str = "\
20You are working from the project's `.todo` list. Inspect the codebase before making changes, \
21complete this task end-to-end in the current repository, run relevant checks when practical, \
22and report what changed plus any follow-up work.";
23
24pub fn run() -> Result<()> {
25 let command = cli::parse_args(env::args_os().skip(1))?;
26 let cwd = env::current_dir()?;
27 let mut stdout = io::stdout();
28 let use_color = stdout.is_terminal() && env::var_os("NO_COLOR").is_none();
29 execute(
30 command,
31 &cwd,
32 &mut stdout,
33 use_color,
34 launch_opencode,
35 switch_to_task_branch,
36 )
37}
38
39fn execute<W, F, G>(
40 command: Command,
41 cwd: &Path,
42 writer: &mut W,
43 use_color: bool,
44 mut run_opencode: F,
45 mut switch_branch: G,
46) -> Result<()>
47where
48 W: Write,
49 F: FnMut(&Path, &str) -> Result<()>,
50 G: FnMut(&Path, &str) -> Result<String>,
51{
52 match command {
53 Command::Help => {
54 writer.write_all(cli::HELP_TEXT.as_bytes())?;
55 }
56 Command::Init => {
57 let path = init_todo_file(cwd)?;
58 writeln!(
59 writer,
60 "{} {}",
61 styled(use_color, "1;34", "Initialized"),
62 path.display()
63 )?;
64 }
65 other => {
66 let todo_path = find_todo_file(cwd)?;
67 let mut todos = TodoList::load(&todo_path)?;
68
69 match other {
70 Command::List(query) => {
71 write_task_list(writer, &todo_path, &todos, query.as_deref(), use_color)?
72 }
73 Command::Add(text) => {
74 let index = todos.add(text)?;
75 let task = &todos.tasks()[index - 1];
76 todos.save(&todo_path)?;
77 writeln!(
78 writer,
79 "{} task {index}: {}",
80 styled(use_color, "36", "Added"),
81 task.text
82 )?;
83 }
84 Command::Done(indices) => {
85 let indices = validate_task_indices(&todos, &indices)?;
86 let mut completed = Vec::new();
87
88 for index in indices {
89 let task = todos.mark_done(index)?.text.clone();
90 completed.push((index, task));
91 }
92
93 todos.save(&todo_path)?;
94 for (index, task) in completed {
95 writeln!(
96 writer,
97 "{} task {index}: {task}",
98 styled(use_color, "32", "Completed")
99 )?;
100 }
101 }
102 Command::Do {
103 indices,
104 branch_name,
105 } => {
106 let indices = validate_task_indices(&todos, &indices)?;
107 let prompt = build_opencode_prompt(&indices);
108 let project_root = todo_path.parent().unwrap_or(cwd);
109
110 if let Some(branch_name) = branch_name.as_deref() {
111 let branch_name = switch_branch(project_root, branch_name)?;
112 writeln!(
113 writer,
114 "{} {}",
115 styled(use_color, "35", "Switched to branch"),
116 branch_name
117 )?;
118 }
119
120 run_opencode(project_root, &prompt)?;
121 for index in indices {
122 let task = todos.task(index)?.text.clone();
123 writeln!(
124 writer,
125 "{} task {index}: {task}",
126 styled(use_color, "34", "Spawned agent for")
127 )?;
128 }
129 }
130 Command::Uncheck(indices) => {
131 let indices = validate_task_indices(&todos, &indices)?;
132 let mut unchecked = Vec::new();
133
134 for index in indices {
135 let task = todos.mark_undone(index)?.text.clone();
136 unchecked.push((index, task));
137 }
138
139 todos.save(&todo_path)?;
140 for (index, task) in unchecked {
141 writeln!(
142 writer,
143 "{} task {index}: {task}",
144 styled(use_color, "33", "Unchecked")
145 )?;
146 }
147 }
148 Command::Scan => {
149 let project_root = todo_path
150 .parent()
151 .unwrap_or_else(|| std::path::Path::new("."));
152 let scanned_tasks = scan_project(project_root)?;
153 let mut existing = todos
154 .tasks()
155 .iter()
156 .map(|task| task.text.clone())
157 .collect::<HashSet<_>>();
158 let mut added = 0usize;
159
160 for task in scanned_tasks {
161 if existing.insert(task.clone()) {
162 todos.add(task)?;
163 added += 1;
164 }
165 }
166
167 if added == 0 {
168 writeln!(writer, "No new TODO comments found in git-tracked files.")?;
169 } else {
170 todos.save(&todo_path)?;
171 writeln!(
172 writer,
173 "{} {added} task{} from git-tracked TODO comments.",
174 styled(use_color, "36", "Added"),
175 if added == 1 { "" } else { "s" }
176 )?;
177 }
178 }
179 Command::Remove(indices) => {
180 let indices = validate_task_indices(&todos, &indices)?;
181 let mut removal_order = indices.clone();
182 removal_order.sort_unstable_by(|left, right| right.cmp(left));
183
184 let mut removed = Vec::new();
185 for index in removal_order {
186 let task = todos.remove(index)?;
187 removed.push((index, task.text));
188 }
189
190 todos.save(&todo_path)?;
191 for index in indices {
192 let (_, task) = removed
193 .iter()
194 .find(|(removed_index, _)| *removed_index == index)
195 .expect("validated task should have been removed");
196 writeln!(
197 writer,
198 "{} task {index}: {task}",
199 styled(use_color, "31", "Removed")
200 )?;
201 }
202 }
203 Command::Next => {
204 if let Some((index, task)) = todos.next_open_task() {
205 writeln!(
206 writer,
207 "{} {index}. {}",
208 styled(use_color, "33", "Next task:"),
209 task.text
210 )?;
211 } else {
212 writeln!(
213 writer,
214 "{}",
215 styled(use_color, "32", "All tasks are complete.")
216 )?;
217 }
218 }
219 Command::Help | Command::Init => unreachable!("handled above"),
220 }
221 }
222 }
223
224 Ok(())
225}
226
227fn build_opencode_prompt(indices: &[usize]) -> String {
228 let task_numbers = indices
229 .iter()
230 .map(|index| index.to_string())
231 .collect::<Vec<_>>()
232 .join(" ");
233
234 format!(
235 "do all the tasks that are numbered {task_numbers} use `to` for seeing todo\n\n{OPENCODE_AGENT_PROMPT}"
236 )
237}
238
239fn launch_opencode(project_root: &Path, prompt: &str) -> Result<()> {
240 let status = ProcessCommand::new("opencode")
241 .arg("--prompt")
242 .arg(prompt)
243 .current_dir(project_root)
244 .status()
245 .map_err(|error| match error.kind() {
246 io::ErrorKind::NotFound => {
247 AppError::CommandFailed("`opencode` was not found in PATH".to_string())
248 }
249 _ => AppError::CommandFailed(format!("failed to launch `opencode`: {error}")),
250 })?;
251
252 if status.success() {
253 Ok(())
254 } else {
255 Err(AppError::CommandFailed(format!(
256 "`opencode --prompt ...` exited with status {status}"
257 )))
258 }
259}
260
261fn switch_to_task_branch(project_root: &Path, branch_name: &str) -> Result<String> {
262 if git_branch_exists(project_root, &branch_name)? {
263 run_git_command(
264 project_root,
265 &["checkout", branch_name],
266 &format!("failed to switch to branch `{branch_name}`"),
267 )?;
268 } else {
269 run_git_command(
270 project_root,
271 &["checkout", "-b", branch_name],
272 &format!("failed to create branch `{branch_name}`"),
273 )?;
274 }
275
276 Ok(branch_name.to_string())
277}
278
279fn git_branch_exists(project_root: &Path, branch_name: &str) -> Result<bool> {
280 let output = ProcessCommand::new("git")
281 .arg("-C")
282 .arg(project_root)
283 .arg("branch")
284 .arg("--list")
285 .arg("--format=%(refname:short)")
286 .arg(branch_name)
287 .output()?;
288
289 if !output.status.success() {
290 return Err(AppError::GitCommandFailed(format!(
291 "failed to inspect git branches: {}",
292 String::from_utf8_lossy(&output.stderr).trim()
293 )));
294 }
295
296 Ok(String::from_utf8_lossy(&output.stdout)
297 .lines()
298 .any(|line| line.trim() == branch_name))
299}
300
301fn run_git_command(project_root: &Path, args: &[&str], failure_message: &str) -> Result<()> {
302 let output = ProcessCommand::new("git")
303 .arg("-C")
304 .arg(project_root)
305 .args(args)
306 .output()?;
307
308 if output.status.success() {
309 return Ok(());
310 }
311
312 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
313 if stderr.is_empty() {
314 Err(AppError::GitCommandFailed(format!(
315 "{failure_message}: git exited with status {}",
316 output.status
317 )))
318 } else {
319 Err(AppError::GitCommandFailed(format!(
320 "{failure_message}: {stderr}"
321 )))
322 }
323}
324
325fn write_task_list<W: Write>(
326 writer: &mut W,
327 todo_path: &Path,
328 todos: &TodoList,
329 query: Option<&str>,
330 use_color: bool,
331) -> Result<()> {
332 writeln!(
333 writer,
334 "{}",
335 styled(
336 use_color,
337 "1;34",
338 &format!("Tasks from {}", todo_path.display())
339 )
340 )?;
341
342 if todos.tasks().is_empty() {
343 writeln!(writer, "No tasks yet.")?;
344 return Ok(());
345 }
346
347 if let Some(query) = query {
348 writeln!(writer, "{} \"{query}\"", styled(use_color, "36", "Filter:"))?;
349 }
350
351 let query = query.map(|value| value.to_lowercase());
352 let mut matches = 0usize;
353 let mut open = 0usize;
354 let mut done = 0usize;
355
356 for (index, task) in todos.tasks().iter().enumerate() {
357 let matches_query = query
358 .as_ref()
359 .map(|value| task.text.to_lowercase().contains(value))
360 .unwrap_or(true);
361
362 if !matches_query {
363 continue;
364 }
365
366 matches += 1;
367 if task.done {
368 done += 1;
369 } else {
370 open += 1;
371 }
372
373 writeln!(
374 writer,
375 "{}. {} {}",
376 index + 1,
377 task_marker(task.done, use_color),
378 task.text
379 )?;
380 }
381
382 if let Some(query) = query {
383 if matches == 0 {
384 writeln!(writer, "No tasks matching \"{query}\".")?;
385 }
386 writeln!(
387 writer,
388 "{} {} {} {} {} {}",
389 styled(use_color, "36", "Matches:"),
390 matches,
391 styled(use_color, "33", "Open:"),
392 open,
393 styled(use_color, "32", "Done:"),
394 done
395 )?;
396 } else {
397 writeln!(
398 writer,
399 "{} {} {} {}",
400 styled(use_color, "33", "Open:"),
401 open,
402 styled(use_color, "32", "Done:"),
403 done
404 )?;
405 }
406
407 Ok(())
408}
409
410fn validate_task_indices(todos: &TodoList, indices: &[usize]) -> Result<Vec<usize>> {
411 let indices = unique_indices(indices);
412 for &index in &indices {
413 let _ = todos.task(index)?;
414 }
415 Ok(indices)
416}
417
418fn unique_indices(indices: &[usize]) -> Vec<usize> {
419 let mut unique = Vec::new();
420 for &index in indices {
421 if !unique.contains(&index) {
422 unique.push(index);
423 }
424 }
425 unique
426}
427
428fn task_marker(done: bool, use_color: bool) -> String {
429 if done {
430 styled(use_color, "32", "[x]")
431 } else {
432 styled(use_color, "33", "[ ]")
433 }
434}
435
436fn styled(use_color: bool, code: &str, text: &str) -> String {
437 if use_color {
438 format!("\u{1b}[{code}m{text}\u{1b}[0m")
439 } else {
440 text.to_string()
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use std::fs;
448 use std::path::PathBuf;
449 use std::time::{SystemTime, UNIX_EPOCH};
450
451 struct TempDir {
452 path: PathBuf,
453 }
454
455 impl TempDir {
456 fn new(name: &str) -> Self {
457 let unique = SystemTime::now()
458 .duration_since(UNIX_EPOCH)
459 .unwrap()
460 .as_nanos();
461 let path = std::env::temp_dir().join(format!("to-lib-{name}-{unique}"));
462 fs::create_dir_all(&path).unwrap();
463 Self { path }
464 }
465 }
466
467 impl Drop for TempDir {
468 fn drop(&mut self) {
469 let _ = fs::remove_dir_all(&self.path);
470 }
471 }
472
473 fn run_git(path: &Path, args: &[&str]) {
474 let status = ProcessCommand::new("git")
475 .args(args)
476 .current_dir(path)
477 .status()
478 .unwrap();
479 assert!(status.success(), "git command failed: {:?}", args);
480 }
481
482 #[test]
483 fn help_command_writes_usage() {
484 let mut output = Vec::new();
485 execute(
486 Command::Help,
487 Path::new("."),
488 &mut output,
489 false,
490 |_root, _prompt| Ok(()),
491 |_root, branch_name| Ok(branch_name.to_string()),
492 )
493 .unwrap();
494
495 let rendered = String::from_utf8(output).unwrap();
496 assert!(rendered.contains("to ls [query]"));
497 assert!(rendered.contains("to init"));
498 assert!(rendered.contains("to do <number> [number ...] [-b <branch-name>]"));
499 }
500
501 #[test]
502 fn builds_opencode_prompt_from_indices() {
503 let prompt = build_opencode_prompt(&[4, 7]);
504 assert!(prompt.contains("do all the tasks that are numbered 4 7 use `to` for seeing todo"));
505 assert!(prompt.contains("Inspect the codebase before making changes"));
506 }
507
508 #[test]
509 fn do_command_runs_opencode_from_project_root() {
510 let temp = TempDir::new("do-command");
511 let project = temp.path.join("workspace");
512 let nested = project.join("service").join("src");
513 fs::create_dir_all(&nested).unwrap();
514 fs::write(
515 project.join(".todo"),
516 "[ ] implement agent runner\n[ ] update CLI parser\n",
517 )
518 .unwrap();
519
520 let mut output = Vec::new();
521 let mut observed_call = None;
522
523 execute(
524 Command::Do {
525 indices: vec![1, 2],
526 branch_name: None,
527 },
528 &nested,
529 &mut output,
530 false,
531 |project_root, prompt| {
532 observed_call = Some((project_root.to_path_buf(), prompt.to_string()));
533 Ok(())
534 },
535 |_root, branch_name| Ok(branch_name.to_string()),
536 )
537 .unwrap();
538
539 let rendered = String::from_utf8(output).unwrap();
540 assert!(rendered.contains("Spawned agent for task 1: implement agent runner"));
541 assert!(rendered.contains("Spawned agent for task 2: update CLI parser"));
542
543 let (project_root, prompt) = observed_call.expect("expected opencode to be invoked");
544 assert_eq!(project_root, project);
545 assert!(prompt.contains("do all the tasks that are numbered 1 2 use `to` for seeing todo"));
546 }
547
548 #[test]
549 fn list_command_filters_tasks_by_query() {
550 let temp = TempDir::new("list-filter");
551 fs::write(
552 temp.path.join(".todo"),
553 "[ ] branch work\n[x] docs cleanup\n[ ] branch follow-up\n",
554 )
555 .unwrap();
556
557 let mut output = Vec::new();
558 execute(
559 Command::List(Some("branch".to_string())),
560 &temp.path,
561 &mut output,
562 false,
563 |_root, _prompt| Ok(()),
564 |_root, branch_name| Ok(branch_name.to_string()),
565 )
566 .unwrap();
567
568 let rendered = String::from_utf8(output).unwrap();
569 assert!(rendered.contains("Filter: \"branch\""));
570 assert!(rendered.contains("1. [ ] branch work"));
571 assert!(rendered.contains("3. [ ] branch follow-up"));
572 assert!(!rendered.contains("docs cleanup"));
573 assert!(rendered.contains("Matches: 2 Open: 2 Done: 0"));
574 }
575
576 #[test]
577 fn done_command_supports_multiple_indices() {
578 let temp = TempDir::new("done-many");
579 fs::write(
580 temp.path.join(".todo"),
581 "[ ] first\n[ ] second\n[ ] third\n",
582 )
583 .unwrap();
584
585 let mut output = Vec::new();
586 execute(
587 Command::Done(vec![1, 3]),
588 &temp.path,
589 &mut output,
590 false,
591 |_root, _prompt| Ok(()),
592 |_root, branch_name| Ok(branch_name.to_string()),
593 )
594 .unwrap();
595
596 let rendered = String::from_utf8(output).unwrap();
597 assert!(rendered.contains("Completed task 1: first"));
598 assert!(rendered.contains("Completed task 3: third"));
599
600 let saved = fs::read_to_string(temp.path.join(".todo")).unwrap();
601 assert_eq!(saved, "[x] first\n[ ] second\n[x] third\n");
602 }
603
604 #[test]
605 fn remove_command_supports_multiple_indices() {
606 let temp = TempDir::new("remove-many");
607 fs::write(
608 temp.path.join(".todo"),
609 "[ ] first\n[ ] second\n[ ] third\n",
610 )
611 .unwrap();
612
613 let mut output = Vec::new();
614 execute(
615 Command::Remove(vec![1, 3]),
616 &temp.path,
617 &mut output,
618 false,
619 |_root, _prompt| Ok(()),
620 |_root, branch_name| Ok(branch_name.to_string()),
621 )
622 .unwrap();
623
624 let rendered = String::from_utf8(output).unwrap();
625 assert!(rendered.contains("Removed task 1: first"));
626 assert!(rendered.contains("Removed task 3: third"));
627
628 let saved = fs::read_to_string(temp.path.join(".todo")).unwrap();
629 assert_eq!(saved, "[ ] second\n");
630 }
631
632 #[test]
633 fn do_command_can_switch_to_named_branch() {
634 let temp = TempDir::new("do-branch");
635 let project = temp.path.join("workspace");
636 fs::create_dir_all(&project).unwrap();
637 fs::write(project.join(".todo"), "[ ] branch task\n[ ] second task\n").unwrap();
638
639 run_git(&project, &["init", "-b", "main"]);
640 run_git(&project, &["config", "user.email", "test@example.com"]);
641 run_git(&project, &["config", "user.name", "Test User"]);
642 run_git(&project, &["add", ".todo"]);
643 run_git(&project, &["commit", "-m", "initial"]);
644
645 let mut output = Vec::new();
646 execute(
647 Command::Do {
648 indices: vec![1, 2],
649 branch_name: Some("feature/batch-work".to_string()),
650 },
651 &project,
652 &mut output,
653 false,
654 |_root, _prompt| Ok(()),
655 switch_to_task_branch,
656 )
657 .unwrap();
658
659 let branch = ProcessCommand::new("git")
660 .arg("-C")
661 .arg(&project)
662 .arg("branch")
663 .arg("--show-current")
664 .output()
665 .unwrap();
666 assert!(branch.status.success());
667 assert_eq!(
668 String::from_utf8_lossy(&branch.stdout).trim(),
669 "feature/batch-work"
670 );
671
672 let rendered = String::from_utf8(output).unwrap();
673 assert!(rendered.contains("Switched to branch feature/batch-work"));
674 assert!(rendered.contains("Spawned agent for task 1: branch task"));
675 assert!(rendered.contains("Spawned agent for task 2: second task"));
676 }
677}