Skip to main content

to/
lib.rs

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}