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;
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}