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, 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    execute(command, &cwd, &mut io::stdout(), launch_opencode)
28}
29
30fn execute<W, F>(command: Command, cwd: &Path, writer: &mut W, mut run_opencode: F) -> Result<()>
31where
32    W: Write,
33    F: FnMut(&Path, &str) -> Result<()>,
34{
35    match command {
36        Command::Help => {
37            writer.write_all(cli::HELP_TEXT.as_bytes())?;
38        }
39        Command::Init => {
40            let path = init_todo_file(cwd)?;
41            writeln!(writer, "Initialized {}", path.display())?;
42        }
43        other => {
44            let todo_path = find_todo_file(cwd)?;
45            let mut todos = TodoList::load(&todo_path)?;
46
47            match other {
48                Command::List => write_task_list(writer, &todo_path, &todos)?,
49                Command::Add(text) => {
50                    let index = todos.add(text)?;
51                    let task = &todos.tasks()[index - 1];
52                    todos.save(&todo_path)?;
53                    writeln!(writer, "Added task {index}: {}", task.text)?;
54                }
55                Command::Done(index) => {
56                    let task = todos.mark_done(index)?.text.clone();
57                    todos.save(&todo_path)?;
58                    writeln!(writer, "Completed task {index}: {task}")?;
59                }
60                Command::Do(index) => {
61                    let task = todos.task(index)?;
62                    let prompt = build_opencode_prompt(index, &task.text);
63                    let project_root = todo_path.parent().unwrap_or(cwd);
64                    run_opencode(project_root, &prompt)?;
65                    writeln!(writer, "Spawned agent for task {index}: {}", task.text)?;
66                }
67                Command::Uncheck(index) => {
68                    let task = todos.mark_undone(index)?.text.clone();
69                    todos.save(&todo_path)?;
70                    writeln!(writer, "Unchecked task {index}: {task}")?;
71                }
72                Command::Scan => {
73                    let project_root = todo_path
74                        .parent()
75                        .unwrap_or_else(|| std::path::Path::new("."));
76                    let scanned_tasks = scan_project(project_root)?;
77                    let mut existing = todos
78                        .tasks()
79                        .iter()
80                        .map(|task| task.text.clone())
81                        .collect::<HashSet<_>>();
82                    let mut added = 0usize;
83
84                    for task in scanned_tasks {
85                        if existing.insert(task.clone()) {
86                            todos.add(task)?;
87                            added += 1;
88                        }
89                    }
90
91                    if added == 0 {
92                        writeln!(writer, "No new TODO comments found in git-tracked files.")?;
93                    } else {
94                        todos.save(&todo_path)?;
95                        writeln!(
96                            writer,
97                            "Added {added} task{} from git-tracked TODO comments.",
98                            if added == 1 { "" } else { "s" }
99                        )?;
100                    }
101                }
102                Command::Remove(index) => {
103                    let task = todos.remove(index)?;
104                    todos.save(&todo_path)?;
105                    writeln!(writer, "Removed task {index}: {}", task.text)?;
106                }
107                Command::Next => {
108                    if let Some((index, task)) = todos.next_open_task() {
109                        writeln!(writer, "Next task: {index}. {}", task.text)?;
110                    } else {
111                        writeln!(writer, "All tasks are complete.")?;
112                    }
113                }
114                Command::Help | Command::Init => unreachable!("handled above"),
115            }
116        }
117    }
118
119    Ok(())
120}
121
122fn build_opencode_prompt(index: usize, task: &str) -> String {
123    format!("Task #{index}: {task}\n\n{OPENCODE_AGENT_PROMPT}")
124}
125
126fn launch_opencode(project_root: &Path, prompt: &str) -> Result<()> {
127    let status = ProcessCommand::new("opencode")
128        .arg("--prompt")
129        .arg(prompt)
130        .current_dir(project_root)
131        .status()
132        .map_err(|error| match error.kind() {
133            io::ErrorKind::NotFound => {
134                AppError::CommandFailed("`opencode` was not found in PATH".to_string())
135            }
136            _ => AppError::CommandFailed(format!("failed to launch `opencode`: {error}")),
137        })?;
138
139    if status.success() {
140        Ok(())
141    } else {
142        Err(AppError::CommandFailed(format!(
143            "`opencode --prompt ...` exited with status {status}"
144        )))
145    }
146}
147
148fn write_task_list<W: Write>(
149    writer: &mut W,
150    todo_path: &std::path::Path,
151    todos: &TodoList,
152) -> Result<()> {
153    writeln!(writer, "Tasks from {}", todo_path.display())?;
154
155    if todos.tasks().is_empty() {
156        writeln!(writer, "No tasks yet.")?;
157        return Ok(());
158    }
159
160    for (index, task) in todos.tasks().iter().enumerate() {
161        let marker = if task.done { "[x]" } else { "[ ]" };
162        writeln!(writer, "{}. {} {}", index + 1, marker, task.text)?;
163    }
164
165    writeln!(
166        writer,
167        "Open: {}  Done: {}",
168        todos.open_count(),
169        todos.done_count()
170    )?;
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::fs;
178    use std::path::PathBuf;
179    use std::time::{SystemTime, UNIX_EPOCH};
180
181    struct TempDir {
182        path: PathBuf,
183    }
184
185    impl TempDir {
186        fn new(name: &str) -> Self {
187            let unique = SystemTime::now()
188                .duration_since(UNIX_EPOCH)
189                .unwrap()
190                .as_nanos();
191            let path = std::env::temp_dir().join(format!("to-lib-{name}-{unique}"));
192            fs::create_dir_all(&path).unwrap();
193            Self { path }
194        }
195    }
196
197    impl Drop for TempDir {
198        fn drop(&mut self) {
199            let _ = fs::remove_dir_all(&self.path);
200        }
201    }
202
203    #[test]
204    fn help_command_writes_usage() {
205        let mut output = Vec::new();
206        execute(
207            Command::Help,
208            Path::new("."),
209            &mut output,
210            |_root, _prompt| Ok(()),
211        )
212        .unwrap();
213
214        let rendered = String::from_utf8(output).unwrap();
215        assert!(rendered.contains("to ls"));
216        assert!(rendered.contains("to init"));
217    }
218
219    #[test]
220    fn builds_opencode_prompt_from_task() {
221        let prompt = build_opencode_prompt(4, "implement agent runner");
222        assert!(prompt.contains("Task #4: implement agent runner"));
223        assert!(prompt.contains("Inspect the codebase before making changes"));
224    }
225
226    #[test]
227    fn do_command_runs_opencode_from_project_root() {
228        let temp = TempDir::new("do-command");
229        let project = temp.path.join("workspace");
230        let nested = project.join("service").join("src");
231        fs::create_dir_all(&nested).unwrap();
232        fs::write(project.join(".todo"), "[ ] implement agent runner\n").unwrap();
233
234        let mut output = Vec::new();
235        let mut observed_call = None;
236
237        execute(
238            Command::Do(1),
239            &nested,
240            &mut output,
241            |project_root, prompt| {
242                observed_call = Some((project_root.to_path_buf(), prompt.to_string()));
243                Ok(())
244            },
245        )
246        .unwrap();
247
248        let rendered = String::from_utf8(output).unwrap();
249        assert!(rendered.contains("Spawned agent for task 1: implement agent runner"));
250
251        let (project_root, prompt) = observed_call.expect("expected opencode to be invoked");
252        assert_eq!(project_root, project);
253        assert!(prompt.contains("Task #1: implement agent runner"));
254    }
255}