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}