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, ListOptions};
14pub use error::{AppError, Result};
15use project::{ensure_agent_docs, 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 let agent_docs = ensure_agent_docs(cwd)?;
59 writeln!(
60 writer,
61 "{} {}",
62 styled(use_color, "1;34", "Initialized"),
63 path.display()
64 )?;
65 for path in agent_docs {
66 writeln!(
67 writer,
68 "{} {}",
69 styled(use_color, "36", "Updated agent doc"),
70 path.display()
71 )?;
72 }
73 }
74 other => {
75 let todo_path = find_todo_file(cwd)?;
76 let mut todos = TodoList::load(&todo_path)?;
77
78 match other {
79 Command::List(options) => {
80 write_task_list(writer, &todo_path, &todos, &options, use_color)?
81 }
82 Command::Add {
83 text,
84 parent,
85 priority,
86 labels,
87 } => {
88 let index = if let Some(parent) = parent {
89 todos.add_child_with_metadata(parent, text, priority, labels)?
90 } else {
91 todos.add_with_metadata(text, priority, labels)?
92 };
93 let task = &todos.tasks()[index - 1];
94 todos.save(&todo_path)?;
95 writeln!(
96 writer,
97 "{} task {index}: {}",
98 styled(use_color, "36", "Added"),
99 task.render_text()
100 )?;
101 }
102 Command::Done(indices) => {
103 let indices = validate_task_indices(&todos, &indices)?;
104 let mut completed = Vec::new();
105
106 for index in indices {
107 let task = todos.mark_done(index)?.render_text();
108 completed.push((index, task));
109 }
110
111 todos.save(&todo_path)?;
112 for (index, task) in completed {
113 writeln!(
114 writer,
115 "{} task {index}: {task}",
116 styled(use_color, "32", "Completed")
117 )?;
118 }
119 }
120 Command::Do {
121 indices,
122 branch_name,
123 create_branch,
124 } => {
125 let indices = validate_task_indices(&todos, &indices)?;
126 let prompt = build_opencode_prompt(&indices);
127 let project_root = todo_path.parent().unwrap_or(cwd);
128 let branch_name = if create_branch {
129 Some(auto_task_branch_name(&todos, &indices)?)
130 } else {
131 branch_name
132 };
133
134 if let Some(branch_name) = branch_name.as_deref() {
135 let branch_name = switch_branch(project_root, branch_name)?;
136 writeln!(
137 writer,
138 "{} {}",
139 styled(use_color, "35", "Switched to branch"),
140 branch_name
141 )?;
142 }
143
144 run_opencode(project_root, &prompt)?;
145 for index in indices {
146 let task = todos.task(index)?.render_text();
147 writeln!(
148 writer,
149 "{} task {index}: {task}",
150 styled(use_color, "34", "Spawned agent for")
151 )?;
152 }
153 }
154 Command::Uncheck(indices) => {
155 let indices = validate_task_indices(&todos, &indices)?;
156 let mut unchecked = Vec::new();
157
158 for index in indices {
159 let task = todos.mark_undone(index)?.render_text();
160 unchecked.push((index, task));
161 }
162
163 todos.save(&todo_path)?;
164 for (index, task) in unchecked {
165 writeln!(
166 writer,
167 "{} task {index}: {task}",
168 styled(use_color, "33", "Unchecked")
169 )?;
170 }
171 }
172 Command::Scan => {
173 let project_root = todo_path
174 .parent()
175 .unwrap_or_else(|| std::path::Path::new("."));
176 let scanned_tasks = scan_project(project_root)?;
177 let mut existing = todos
178 .tasks()
179 .iter()
180 .map(|task| task.text.clone())
181 .collect::<HashSet<_>>();
182 let mut added = 0usize;
183
184 for task in scanned_tasks {
185 if existing.insert(task.clone()) {
186 todos.add(task)?;
187 added += 1;
188 }
189 }
190
191 if added == 0 {
192 writeln!(writer, "No new TODO comments found in git-tracked files.")?;
193 } else {
194 todos.save(&todo_path)?;
195 writeln!(
196 writer,
197 "{} {added} task{} from git-tracked TODO comments.",
198 styled(use_color, "36", "Added"),
199 if added == 1 { "" } else { "s" }
200 )?;
201 }
202 }
203 Command::Remove(indices) => {
204 let indices = validate_task_indices(&todos, &indices)?;
205 let mut removal_order = indices.clone();
206 removal_order.sort_unstable_by(|left, right| right.cmp(left));
207
208 let mut removed = Vec::new();
209 for index in removal_order {
210 let task = todos.remove(index)?;
211 removed.push((index, task.render_text()));
212 }
213
214 todos.save(&todo_path)?;
215 for index in indices {
216 let (_, task) = removed
217 .iter()
218 .find(|(removed_index, _)| *removed_index == index)
219 .expect("validated task should have been removed");
220 writeln!(
221 writer,
222 "{} task {index}: {task}",
223 styled(use_color, "31", "Removed")
224 )?;
225 }
226 }
227 Command::Next => {
228 if let Some((index, task)) = todos.next_open_task() {
229 writeln!(
230 writer,
231 "{} {index}. {}",
232 styled(use_color, "33", "Next task:"),
233 task.render_text()
234 )?;
235 } else {
236 writeln!(
237 writer,
238 "{}",
239 styled(use_color, "32", "All tasks are complete.")
240 )?;
241 }
242 }
243 Command::Tree(index) => {
244 let tasks = todos.subtree(index)?;
245 for (offset, task) in tasks.iter().enumerate() {
246 writeln!(
247 writer,
248 "{}. {} {}{}",
249 index + offset,
250 task_marker(task.done, use_color),
251 " ".repeat(task.indent.saturating_sub(tasks[0].indent)),
252 task.render_text()
253 )?;
254 }
255 }
256 Command::Help | Command::Init => unreachable!("handled above"),
257 }
258 }
259 }
260
261 Ok(())
262}
263
264fn build_opencode_prompt(indices: &[usize]) -> String {
265 let task_numbers = indices
266 .iter()
267 .map(|index| index.to_string())
268 .collect::<Vec<_>>()
269 .join(" ");
270
271 format!(
272 "do all the tasks that are numbered {task_numbers} use `to` for seeing todo\n\n{OPENCODE_AGENT_PROMPT}"
273 )
274}
275
276fn auto_task_branch_name(todos: &TodoList, indices: &[usize]) -> Result<String> {
277 let slug = if indices.len() == 1 {
278 let index = indices[0];
279 let task = todos.task(index)?;
280 let slug = slugify_task_text(&task.text);
281 if slug.is_empty() {
282 format!("task-{index}")
283 } else {
284 slug
285 }
286 } else {
287 let numbers = indices
288 .iter()
289 .map(|index| index.to_string())
290 .collect::<Vec<_>>()
291 .join("-");
292 format!("tasks-{numbers}")
293 };
294
295 Ok(format!("feature/{slug}"))
296}
297
298fn slugify_task_text(text: &str) -> String {
299 let mut slug = String::new();
300 let mut last_was_dash = false;
301
302 for character in text.chars() {
303 if character.is_ascii_alphanumeric() {
304 slug.push(character.to_ascii_lowercase());
305 last_was_dash = false;
306 } else if !last_was_dash && !slug.is_empty() {
307 slug.push('-');
308 last_was_dash = true;
309 }
310
311 if slug.len() >= 48 {
312 break;
313 }
314 }
315
316 while slug.ends_with('-') {
317 slug.pop();
318 }
319
320 slug
321}
322
323fn launch_opencode(project_root: &Path, prompt: &str) -> Result<()> {
324 let status = ProcessCommand::new("opencode")
325 .arg("--prompt")
326 .arg(prompt)
327 .current_dir(project_root)
328 .status()
329 .map_err(|error| match error.kind() {
330 io::ErrorKind::NotFound => {
331 AppError::CommandFailed("`opencode` was not found in PATH".to_string())
332 }
333 _ => AppError::CommandFailed(format!("failed to launch `opencode`: {error}")),
334 })?;
335
336 if status.success() {
337 Ok(())
338 } else {
339 Err(AppError::CommandFailed(format!(
340 "`opencode --prompt ...` exited with status {status}"
341 )))
342 }
343}
344
345fn switch_to_task_branch(project_root: &Path, branch_name: &str) -> Result<String> {
346 if git_branch_exists(project_root, &branch_name)? {
347 run_git_command(
348 project_root,
349 &["checkout", branch_name],
350 &format!("failed to switch to branch `{branch_name}`"),
351 )?;
352 } else {
353 run_git_command(
354 project_root,
355 &["checkout", "-b", branch_name],
356 &format!("failed to create branch `{branch_name}`"),
357 )?;
358 }
359
360 Ok(branch_name.to_string())
361}
362
363fn git_branch_exists(project_root: &Path, branch_name: &str) -> Result<bool> {
364 let output = ProcessCommand::new("git")
365 .arg("-C")
366 .arg(project_root)
367 .arg("branch")
368 .arg("--list")
369 .arg("--format=%(refname:short)")
370 .arg(branch_name)
371 .output()?;
372
373 if !output.status.success() {
374 return Err(AppError::GitCommandFailed(format!(
375 "failed to inspect git branches: {}",
376 String::from_utf8_lossy(&output.stderr).trim()
377 )));
378 }
379
380 Ok(String::from_utf8_lossy(&output.stdout)
381 .lines()
382 .any(|line| line.trim() == branch_name))
383}
384
385fn run_git_command(project_root: &Path, args: &[&str], failure_message: &str) -> Result<()> {
386 let output = ProcessCommand::new("git")
387 .arg("-C")
388 .arg(project_root)
389 .args(args)
390 .output()?;
391
392 if output.status.success() {
393 return Ok(());
394 }
395
396 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
397 if stderr.is_empty() {
398 Err(AppError::GitCommandFailed(format!(
399 "{failure_message}: git exited with status {}",
400 output.status
401 )))
402 } else {
403 Err(AppError::GitCommandFailed(format!(
404 "{failure_message}: {stderr}"
405 )))
406 }
407}
408
409fn write_task_list<W: Write>(
410 writer: &mut W,
411 todo_path: &Path,
412 todos: &TodoList,
413 options: &ListOptions,
414 use_color: bool,
415) -> Result<()> {
416 writeln!(
417 writer,
418 "{}",
419 styled(
420 use_color,
421 "1;34",
422 &format!("Tasks from {}", todo_path.display())
423 )
424 )?;
425
426 if todos.tasks().is_empty() {
427 writeln!(writer, "No tasks yet.")?;
428 return Ok(());
429 }
430
431 if let Some(query) = &options.query {
432 writeln!(writer, "{} \"{query}\"", styled(use_color, "36", "Filter:"))?;
433 }
434 if let Some(priority) = options.priority {
435 writeln!(
436 writer,
437 "{} {}",
438 styled(use_color, "36", "Priority:"),
439 priority.as_str()
440 )?;
441 }
442 if !options.labels.is_empty() {
443 writeln!(
444 writer,
445 "{} {}",
446 styled(use_color, "36", "Labels:"),
447 options
448 .labels
449 .iter()
450 .map(|label| format!("#{label}"))
451 .collect::<Vec<_>>()
452 .join(", ")
453 )?;
454 }
455
456 let query = options.query.as_ref().map(|value| value.to_lowercase());
457 let mut matches = 0usize;
458 let mut open = 0usize;
459 let mut done = 0usize;
460
461 for (index, task) in todos.tasks().iter().enumerate() {
462 let matches_query = query
463 .as_ref()
464 .map(|value| task.text.to_lowercase().contains(value))
465 .unwrap_or(true);
466 let matches_priority = options
467 .priority
468 .map(|priority| task.priority == Some(priority))
469 .unwrap_or(true);
470 let matches_labels = options
471 .labels
472 .iter()
473 .all(|label| task.labels.contains(label));
474
475 if !(matches_query && matches_priority && matches_labels) {
476 continue;
477 }
478
479 matches += 1;
480 if task.done {
481 done += 1;
482 } else {
483 open += 1;
484 }
485
486 writeln!(
487 writer,
488 "{}. {} {}{}",
489 index + 1,
490 task_marker(task.done, use_color),
491 " ".repeat(task.indent),
492 task.render_text()
493 )?;
494 }
495
496 if options.query.is_some() || options.priority.is_some() || !options.labels.is_empty() {
497 if matches == 0 {
498 writeln!(writer, "No matching tasks.")?;
499 }
500 writeln!(
501 writer,
502 "{} {} {} {} {} {}",
503 styled(use_color, "36", "Matches:"),
504 matches,
505 styled(use_color, "33", "Open:"),
506 open,
507 styled(use_color, "32", "Done:"),
508 done
509 )?;
510 } else {
511 writeln!(
512 writer,
513 "{} {} {} {}",
514 styled(use_color, "33", "Open:"),
515 open,
516 styled(use_color, "32", "Done:"),
517 done
518 )?;
519 }
520
521 Ok(())
522}
523
524fn validate_task_indices(todos: &TodoList, indices: &[usize]) -> Result<Vec<usize>> {
525 let indices = unique_indices(indices);
526 for &index in &indices {
527 let _ = todos.task(index)?;
528 }
529 Ok(indices)
530}
531
532fn unique_indices(indices: &[usize]) -> Vec<usize> {
533 let mut unique = Vec::new();
534 for &index in indices {
535 if !unique.contains(&index) {
536 unique.push(index);
537 }
538 }
539 unique
540}
541
542fn task_marker(done: bool, use_color: bool) -> String {
543 if done {
544 styled(use_color, "32", "[x]")
545 } else {
546 styled(use_color, "33", "[ ]")
547 }
548}
549
550fn styled(use_color: bool, code: &str, text: &str) -> String {
551 if use_color {
552 format!("\u{1b}[{code}m{text}\u{1b}[0m")
553 } else {
554 text.to_string()
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::todo::Priority;
562 use std::fs;
563 use std::path::PathBuf;
564 use std::time::{SystemTime, UNIX_EPOCH};
565
566 struct TempDir {
567 path: PathBuf,
568 }
569
570 impl TempDir {
571 fn new(name: &str) -> Self {
572 let unique = SystemTime::now()
573 .duration_since(UNIX_EPOCH)
574 .unwrap()
575 .as_nanos();
576 let path = std::env::temp_dir().join(format!("to-lib-{name}-{unique}"));
577 fs::create_dir_all(&path).unwrap();
578 Self { path }
579 }
580 }
581
582 impl Drop for TempDir {
583 fn drop(&mut self) {
584 let _ = fs::remove_dir_all(&self.path);
585 }
586 }
587
588 fn run_git(path: &Path, args: &[&str]) {
589 let status = ProcessCommand::new("git")
590 .args(args)
591 .current_dir(path)
592 .status()
593 .unwrap();
594 assert!(status.success(), "git command failed: {:?}", args);
595 }
596
597 #[test]
598 fn help_command_writes_usage() {
599 let mut output = Vec::new();
600 execute(
601 Command::Help,
602 Path::new("."),
603 &mut output,
604 false,
605 |_root, _prompt| Ok(()),
606 |_root, branch_name| Ok(branch_name.to_string()),
607 )
608 .unwrap();
609
610 let rendered = String::from_utf8(output).unwrap();
611 assert!(rendered.contains("to ls [query]"));
612 assert!(rendered.contains("to init"));
613 assert!(rendered.contains("to add \"task text\" --parent <number>"));
614 assert!(
615 rendered.contains("to add \"task text\" --priority <high|medium|low> --label <label>")
616 );
617 assert!(rendered.contains("to do <number> [number ...] [-b <branch-name>]"));
618 assert!(rendered.contains("to do <number> [number ...] --create-branch"));
619 assert!(rendered.contains("to tree <number>"));
620 }
621
622 #[test]
623 fn builds_opencode_prompt_from_indices() {
624 let prompt = build_opencode_prompt(&[4, 7]);
625 assert!(prompt.contains("do all the tasks that are numbered 4 7 use `to` for seeing todo"));
626 assert!(prompt.contains("Inspect the codebase before making changes"));
627 }
628
629 #[test]
630 fn do_command_runs_opencode_from_project_root() {
631 let temp = TempDir::new("do-command");
632 let project = temp.path.join("workspace");
633 let nested = project.join("service").join("src");
634 fs::create_dir_all(&nested).unwrap();
635 fs::write(
636 project.join(".todo"),
637 "[ ] implement agent runner\n[ ] update CLI parser\n",
638 )
639 .unwrap();
640
641 let mut output = Vec::new();
642 let mut observed_call = None;
643
644 execute(
645 Command::Do {
646 indices: vec![1, 2],
647 branch_name: None,
648 create_branch: false,
649 },
650 &nested,
651 &mut output,
652 false,
653 |project_root, prompt| {
654 observed_call = Some((project_root.to_path_buf(), prompt.to_string()));
655 Ok(())
656 },
657 |_root, branch_name| Ok(branch_name.to_string()),
658 )
659 .unwrap();
660
661 let rendered = String::from_utf8(output).unwrap();
662 assert!(rendered.contains("Spawned agent for task 1: implement agent runner"));
663 assert!(rendered.contains("Spawned agent for task 2: update CLI parser"));
664
665 let (project_root, prompt) = observed_call.expect("expected opencode to be invoked");
666 assert_eq!(project_root, project);
667 assert!(prompt.contains("do all the tasks that are numbered 1 2 use `to` for seeing todo"));
668 }
669
670 #[test]
671 fn list_command_filters_tasks_by_query() {
672 let temp = TempDir::new("list-filter");
673 fs::write(
674 temp.path.join(".todo"),
675 "[ ] branch work\n[x] docs cleanup\n[ ] branch follow-up\n",
676 )
677 .unwrap();
678
679 let mut output = Vec::new();
680 execute(
681 Command::List(ListOptions {
682 query: Some("branch".to_string()),
683 priority: None,
684 labels: Vec::new(),
685 }),
686 &temp.path,
687 &mut output,
688 false,
689 |_root, _prompt| Ok(()),
690 |_root, branch_name| Ok(branch_name.to_string()),
691 )
692 .unwrap();
693
694 let rendered = String::from_utf8(output).unwrap();
695 assert!(rendered.contains("Filter: \"branch\""));
696 assert!(rendered.contains("1. [ ] branch work"));
697 assert!(rendered.contains("3. [ ] branch follow-up"));
698 assert!(!rendered.contains("docs cleanup"));
699 assert!(rendered.contains("Matches: 2 Open: 2 Done: 0"));
700 }
701
702 #[test]
703 fn list_command_filters_tasks_by_metadata() {
704 let temp = TempDir::new("list-metadata");
705 fs::write(
706 temp.path.join(".todo"),
707 "[ ] api fix @high #backend #api\n[x] ui polish @low #frontend\n",
708 )
709 .unwrap();
710
711 let mut output = Vec::new();
712 execute(
713 Command::List(ListOptions {
714 query: None,
715 priority: Some(Priority::High),
716 labels: vec!["backend".to_string()],
717 }),
718 &temp.path,
719 &mut output,
720 false,
721 |_root, _prompt| Ok(()),
722 |_root, branch_name| Ok(branch_name.to_string()),
723 )
724 .unwrap();
725
726 let rendered = String::from_utf8(output).unwrap();
727 assert!(rendered.contains("Priority: high"));
728 assert!(rendered.contains("Labels: #backend"));
729 assert!(rendered.contains("1. [ ] api fix @high #backend #api"));
730 assert!(!rendered.contains("ui polish"));
731 assert!(rendered.contains("Matches: 1 Open: 1 Done: 0"));
732 }
733
734 #[test]
735 fn done_command_supports_multiple_indices() {
736 let temp = TempDir::new("done-many");
737 fs::write(
738 temp.path.join(".todo"),
739 "[ ] first\n[ ] second\n[ ] third\n",
740 )
741 .unwrap();
742
743 let mut output = Vec::new();
744 execute(
745 Command::Done(vec![1, 3]),
746 &temp.path,
747 &mut output,
748 false,
749 |_root, _prompt| Ok(()),
750 |_root, branch_name| Ok(branch_name.to_string()),
751 )
752 .unwrap();
753
754 let rendered = String::from_utf8(output).unwrap();
755 assert!(rendered.contains("Completed task 1: first"));
756 assert!(rendered.contains("Completed task 3: third"));
757
758 let saved = fs::read_to_string(temp.path.join(".todo")).unwrap();
759 assert_eq!(saved, "[x] first\n[ ] second\n[x] third\n");
760 }
761
762 #[test]
763 fn remove_command_supports_multiple_indices() {
764 let temp = TempDir::new("remove-many");
765 fs::write(
766 temp.path.join(".todo"),
767 "[ ] first\n[ ] second\n[ ] third\n",
768 )
769 .unwrap();
770
771 let mut output = Vec::new();
772 execute(
773 Command::Remove(vec![1, 3]),
774 &temp.path,
775 &mut output,
776 false,
777 |_root, _prompt| Ok(()),
778 |_root, branch_name| Ok(branch_name.to_string()),
779 )
780 .unwrap();
781
782 let rendered = String::from_utf8(output).unwrap();
783 assert!(rendered.contains("Removed task 1: first"));
784 assert!(rendered.contains("Removed task 3: third"));
785
786 let saved = fs::read_to_string(temp.path.join(".todo")).unwrap();
787 assert_eq!(saved, "[ ] second\n");
788 }
789
790 #[test]
791 fn do_command_can_switch_to_named_branch() {
792 let temp = TempDir::new("do-branch");
793 let project = temp.path.join("workspace");
794 fs::create_dir_all(&project).unwrap();
795 fs::write(project.join(".todo"), "[ ] branch task\n[ ] second task\n").unwrap();
796
797 run_git(&project, &["init", "-b", "main"]);
798 run_git(&project, &["config", "user.email", "test@example.com"]);
799 run_git(&project, &["config", "user.name", "Test User"]);
800 run_git(&project, &["add", ".todo"]);
801 run_git(&project, &["commit", "-m", "initial"]);
802
803 let mut output = Vec::new();
804 execute(
805 Command::Do {
806 indices: vec![1, 2],
807 branch_name: Some("feature/batch-work".to_string()),
808 create_branch: false,
809 },
810 &project,
811 &mut output,
812 false,
813 |_root, _prompt| Ok(()),
814 switch_to_task_branch,
815 )
816 .unwrap();
817
818 let branch = ProcessCommand::new("git")
819 .arg("-C")
820 .arg(&project)
821 .arg("branch")
822 .arg("--show-current")
823 .output()
824 .unwrap();
825 assert!(branch.status.success());
826 assert_eq!(
827 String::from_utf8_lossy(&branch.stdout).trim(),
828 "feature/batch-work"
829 );
830
831 let rendered = String::from_utf8(output).unwrap();
832 assert!(rendered.contains("Switched to branch feature/batch-work"));
833 assert!(rendered.contains("Spawned agent for task 1: branch task"));
834 assert!(rendered.contains("Spawned agent for task 2: second task"));
835 }
836
837 #[test]
838 fn add_command_can_create_subtask() {
839 let temp = TempDir::new("add-subtask");
840 fs::write(temp.path.join(".todo"), "[ ] parent\n[ ] sibling\n").unwrap();
841
842 let mut output = Vec::new();
843 execute(
844 Command::Add {
845 text: "child task".to_string(),
846 parent: Some(1),
847 priority: None,
848 labels: Vec::new(),
849 },
850 &temp.path,
851 &mut output,
852 false,
853 |_root, _prompt| Ok(()),
854 |_root, branch_name| Ok(branch_name.to_string()),
855 )
856 .unwrap();
857
858 let rendered = String::from_utf8(output).unwrap();
859 assert!(rendered.contains("Added task 2: child task"));
860
861 let saved = fs::read_to_string(temp.path.join(".todo")).unwrap();
862 assert_eq!(saved, "[ ] parent\n [ ] child task\n[ ] sibling\n");
863 }
864
865 #[test]
866 fn add_command_can_create_task_with_metadata() {
867 let temp = TempDir::new("add-metadata");
868 fs::write(temp.path.join(".todo"), "").unwrap();
869
870 let mut output = Vec::new();
871 execute(
872 Command::Add {
873 text: "api fix".to_string(),
874 parent: None,
875 priority: Some(Priority::High),
876 labels: vec!["backend".to_string(), "api".to_string()],
877 },
878 &temp.path,
879 &mut output,
880 false,
881 |_root, _prompt| Ok(()),
882 |_root, branch_name| Ok(branch_name.to_string()),
883 )
884 .unwrap();
885
886 let rendered = String::from_utf8(output).unwrap();
887 assert!(rendered.contains("Added task 1: api fix @high #backend #api"));
888
889 let saved = fs::read_to_string(temp.path.join(".todo")).unwrap();
890 assert_eq!(saved, "[ ] api fix @high #backend #api\n");
891 }
892
893 #[test]
894 fn tree_command_shows_task_subtree() {
895 let temp = TempDir::new("tree");
896 fs::write(
897 temp.path.join(".todo"),
898 "[ ] parent\n [ ] child\n[ ] sibling\n",
899 )
900 .unwrap();
901
902 let mut output = Vec::new();
903 execute(
904 Command::Tree(1),
905 &temp.path,
906 &mut output,
907 false,
908 |_root, _prompt| Ok(()),
909 |_root, branch_name| Ok(branch_name.to_string()),
910 )
911 .unwrap();
912
913 let rendered = String::from_utf8(output).unwrap();
914 assert!(rendered.contains("1. [ ] parent"));
915 assert!(rendered.contains("2. [ ] child"));
916 assert!(!rendered.contains("sibling"));
917 }
918
919 #[test]
920 fn do_command_can_create_branch_from_task_text() {
921 let temp = TempDir::new("do-auto-branch");
922 fs::write(
923 temp.path.join(".todo"),
924 "[ ] Add Claude/AGENTS context export!\n",
925 )
926 .unwrap();
927
928 let mut output = Vec::new();
929 let mut observed_branch = None;
930
931 execute(
932 Command::Do {
933 indices: vec![1],
934 branch_name: None,
935 create_branch: true,
936 },
937 &temp.path,
938 &mut output,
939 false,
940 |_root, _prompt| Ok(()),
941 |_root, branch_name| {
942 observed_branch = Some(branch_name.to_string());
943 Ok(branch_name.to_string())
944 },
945 )
946 .unwrap();
947
948 assert_eq!(
949 observed_branch.as_deref(),
950 Some("feature/add-claude-agents-context-export")
951 );
952 let rendered = String::from_utf8(output).unwrap();
953 assert!(rendered.contains("Switched to branch feature/add-claude-agents-context-export"));
954 assert!(rendered.contains("Spawned agent for task 1: Add Claude/AGENTS context export!"));
955 }
956}