1use serde::{Deserialize, Deserializer, Serialize};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum TodoStatus {
14 #[default]
15 Pending,
16 InProgress,
17 Completed,
18 Cancelled,
19}
20
21impl TodoStatus {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Pending => "pending",
25 Self::InProgress => "in_progress",
26 Self::Completed => "completed",
27 Self::Cancelled => "cancelled",
28 }
29 }
30
31 pub fn mark(self) -> &'static str {
33 match self {
34 Self::Pending => "☐",
35 Self::InProgress => "▶",
36 Self::Completed => "☑",
37 Self::Cancelled => "✗",
38 }
39 }
40
41 pub fn is_terminal(self) -> bool {
43 matches!(self, Self::Completed | Self::Cancelled)
44 }
45
46 pub fn parse(s: &str) -> Self {
47 match s {
48 "in_progress" => Self::InProgress,
49 "completed" => Self::Completed,
50 "cancelled" => Self::Cancelled,
51 _ => Self::Pending,
52 }
53 }
54}
55
56impl<'de> Deserialize<'de> for TodoStatus {
57 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
58 let s = String::deserialize(deserializer)?;
59 Ok(Self::parse(&s))
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct TodoItem {
66 #[serde(default)]
67 pub id: String,
68 #[serde(default)]
69 pub content: String,
70 #[serde(default)]
71 pub status: TodoStatus,
72}
73
74impl TodoItem {
75 pub fn new(id: impl Into<String>, content: impl Into<String>, status: TodoStatus) -> Self {
76 Self {
77 id: id.into(),
78 content: content.into(),
79 status,
80 }
81 }
82
83 pub fn line(&self) -> String {
84 format!("{} {}", self.status.mark(), self.content)
85 }
86}
87
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub struct TodoList {
91 #[serde(default)]
92 pub todos: Vec<TodoItem>,
93}
94
95pub type TodoSink = Arc<dyn Fn(Vec<TodoItem>) + Send + Sync>;
97
98pub fn todos_path(working_dir: &Path, session_id: Option<&str>) -> PathBuf {
103 let dir = crate::paths::project_dir(working_dir);
104 match session_id.map(str::trim).filter(|s| !s.is_empty()) {
105 Some(id) => dir.join("todos").join(format!("{id}.json")),
106 None => dir.join("todos.json"),
107 }
108}
109
110pub fn load_todos(working_dir: &Path, session_id: Option<&str>) -> Vec<TodoItem> {
112 let path = todos_path(working_dir, session_id);
113 let Ok(raw) = std::fs::read_to_string(path) else {
114 return Vec::new();
115 };
116 serde_json::from_str::<TodoList>(&raw)
117 .map(|list| list.todos)
118 .unwrap_or_default()
119}
120
121pub fn save_todos(
123 working_dir: &Path,
124 session_id: Option<&str>,
125 todos: &[TodoItem],
126) -> Result<(), String> {
127 let path = todos_path(working_dir, session_id);
128 std::fs::create_dir_all(path.parent().unwrap_or(working_dir))
131 .map_err(|e| format!("creating todo dir: {e}"))?;
132 let value = serde_json::json!({ "todos": todos });
135 let json = serde_json::to_string_pretty(&value).unwrap_or(value.to_string());
136 let tmp = path.with_extension("json.tmp");
139 std::fs::write(&tmp, json).map_err(|e| format!("writing todos: {e}"))?;
140 std::fs::rename(&tmp, &path).map_err(|e| format!("writing todos: {e}"))
141}
142
143pub fn apply_todo_update(
147 mut existing: Vec<TodoItem>,
148 incoming: Vec<TodoItem>,
149 merge: bool,
150) -> Vec<TodoItem> {
151 if !merge {
152 existing.clear();
153 }
154 for mut new in incoming {
155 if new.id.is_empty() {
156 new.id = free_id(&existing);
157 existing.push(new);
158 } else if let Some(old) = existing.iter_mut().find(|t| t.id == new.id) {
159 *old = new;
160 } else {
161 existing.push(new);
162 }
163 }
164 existing
165}
166
167fn free_id(todos: &[TodoItem]) -> String {
169 let mut n = todos.len() + 1;
170 while todos.iter().any(|t| t.id == n.to_string()) {
171 n += 1;
172 }
173 n.to_string()
174}
175
176pub fn todo_warnings(todos: &[TodoItem]) -> Vec<String> {
178 let mut out = Vec::new();
179 let active = todos
180 .iter()
181 .filter(|t| t.status == TodoStatus::InProgress)
182 .count();
183 if active > 1 {
184 out.push(format!(
185 "{active} items are in_progress; keep exactly one in_progress at a time."
186 ));
187 }
188 for t in todos.iter().filter(|t| t.content.trim().is_empty()) {
189 out.push(format!("item {:?} has no content.", t.id));
190 }
191 out
192}
193
194pub fn progress_line(todos: &[TodoItem]) -> String {
196 let done = terminal_count(todos);
197 let next = todos
198 .iter()
199 .find(|t| t.status == TodoStatus::InProgress)
200 .or_else(|| todos.iter().find(|t| t.status == TodoStatus::Pending));
201 match next {
202 Some(t) => format!("{done}/{} done · next: {}", todos.len(), t.content),
203 None => format!("all {done} done"),
204 }
205}
206
207pub fn apply_todowrite_args(
212 existing: &[TodoItem],
213 input: &serde_json::Value,
214) -> Option<Vec<TodoItem>> {
215 let incoming: Vec<TodoItem> = serde_json::from_value(input.get("todos")?.clone()).ok()?;
216 let merge = input.get("merge").and_then(|v| v.as_bool()).unwrap_or(true);
217 Some(apply_todo_update(existing.to_vec(), incoming, merge))
218}
219
220pub fn terminal_count(todos: &[TodoItem]) -> usize {
222 todos.iter().filter(|t| t.status.is_terminal()).count()
223}
224
225pub fn all_terminal(todos: &[TodoItem]) -> bool {
227 !todos.is_empty() && todos.iter().all(|t| t.status.is_terminal())
228}
229
230pub fn has_open(todos: &[TodoItem]) -> bool {
232 todos.iter().any(|t| !t.status.is_terminal())
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn status_item_persist_and_merge() {
241 for (s, mark) in [
242 (TodoStatus::Pending, "☐"),
243 (TodoStatus::InProgress, "▶"),
244 (TodoStatus::Completed, "☑"),
245 (TodoStatus::Cancelled, "✗"),
246 ] {
247 assert!(!s.as_str().is_empty());
248 assert_eq!(s.mark(), mark);
249 }
250 assert!(!TodoStatus::Pending.is_terminal());
251 assert!(TodoStatus::Completed.is_terminal());
252 assert_eq!(TodoStatus::parse("in_progress"), TodoStatus::InProgress);
253 assert_eq!(TodoStatus::parse("completed"), TodoStatus::Completed);
254 assert_eq!(TodoStatus::parse("cancelled"), TodoStatus::Cancelled);
255 assert_eq!(TodoStatus::parse("nope"), TodoStatus::Pending);
256 assert_eq!(TodoStatus::default(), TodoStatus::Pending);
257
258 let json = r#"["pending","in_progress","completed","cancelled","x"]"#;
259 let parsed: Vec<TodoStatus> = serde_json::from_str(json).unwrap();
260 assert_eq!(parsed[0], TodoStatus::Pending);
261 assert_eq!(parsed[4], TodoStatus::Pending);
262
263 let item = TodoItem::new("1", "do it", TodoStatus::InProgress);
264 assert!(item.line().contains("do it"));
265
266 let dir = tempfile::tempdir().unwrap();
267 assert!(load_todos(dir.path(), None).is_empty());
268 assert!(todos_path(dir.path(), None).ends_with("todos.json"));
269 assert!(todos_path(dir.path(), Some(" ")).ends_with("todos.json"));
270 assert!(todos_path(dir.path(), Some("sid")).ends_with("sid.json"));
271
272 let items = vec![
273 TodoItem::new("a", "one", TodoStatus::Pending),
274 TodoItem::new("b", "two", TodoStatus::Completed),
275 ];
276 save_todos(dir.path(), Some("sid"), &items).unwrap();
277 let loaded = load_todos(dir.path(), Some("sid"));
278 assert_eq!(loaded.len(), 2);
279 std::fs::write(todos_path(dir.path(), Some("bad")), "not-json").unwrap();
280 assert!(load_todos(dir.path(), Some("bad")).is_empty());
281
282 let merged = apply_todo_update(
283 items.clone(),
284 vec![TodoItem::new("a", "one-upd", TodoStatus::Completed)],
285 true,
286 );
287 assert_eq!(merged[0].content, "one-upd");
288 let replaced = apply_todo_update(
289 items.clone(),
290 vec![TodoItem::new("z", "z", TodoStatus::Pending)],
291 false,
292 );
293 assert_eq!(replaced.len(), 1);
294 let appended = apply_todo_update(
295 items.clone(),
296 vec![TodoItem::new("", "no-id", TodoStatus::Pending)],
297 true,
298 );
299 assert_eq!(appended.len(), 3);
300 assert_eq!(terminal_count(&items), 1);
301 assert!(!all_terminal(&items));
302 assert!(!all_terminal(&[]));
303 assert!(has_open(&items));
304 assert!(!has_open(&[]));
305 assert!(!has_open(&[TodoItem::new(
306 "c",
307 "done",
308 TodoStatus::Cancelled
309 )]));
310 assert!(all_terminal(&[TodoItem::new(
311 "c",
312 "done",
313 TodoStatus::Cancelled
314 )]));
315 }
316}