Skip to main content

whycodes_core/
todo.rs

1//! Session todo list (sticky TUI panel).
2//!
3//! Tools write through [`TodoSink`]; the host maps that onto [`crate::todo`]
4//! events. Storage is session-scoped under `.whycodes/todos/<session_id>.json`.
5
6use serde::{Deserialize, Deserializer, Serialize};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10/// Status of one todo item.
11#[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    /// ASCII mark used in the TUI panel and sidebar.
32    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    /// True when the item is finished (done or skipped).
42    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/// One row in the session todo list.
64#[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/// On-disk envelope.
89#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub struct TodoList {
91    #[serde(default)]
92    pub todos: Vec<TodoItem>,
93}
94
95/// Callback the host (agent loop) installs so `todowrite` can reach the TUI.
96pub type TodoSink = Arc<dyn Fn(Vec<TodoItem>) + Send + Sync>;
97
98/// Path of the session todo file.
99///
100/// With a non-empty `session_id`: `.whycodes/todos/<id>.json`.
101/// Otherwise (tests, missing id): `.whycodes/todos.json`.
102pub 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
110/// Load todos. Missing or invalid files yield an empty list.
111pub 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
121/// Persist the full list. Creates `.whycodes` / `.whycodes/todos` as needed.
122pub 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    // `todos_path` is always nested under `.whycodes/`, so parent exists.
129    // `unwrap_or` keeps the llvm-cov 100% floor (no uncovered `if let` else).
130    std::fs::create_dir_all(path.parent().unwrap_or(working_dir))
131        .map_err(|e| format!("creating todo dir: {e}"))?;
132    // `Value::to_string` cannot fail; `unwrap_or` evaluates the fallback
133    // eagerly so llvm-cov does not see a dead `unwrap_or_else` closure.
134    let value = serde_json::json!({ "todos": todos });
135    let json = serde_json::to_string_pretty(&value).unwrap_or(value.to_string());
136    // Write then rename: a crash mid-write must not leave a torn file, which
137    // `load_todos` would read as an empty list and the next merge would keep.
138    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
143/// Merge incoming items into `existing` by `id` (an id written twice keeps
144/// the last). Empty ids always append, under a fresh numeric id so a later
145/// update can reach them. `merge: false` starts from an empty list.
146pub 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
167/// First number from `len + 1` up that is not already an id.
168fn 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
176/// Problems in a list the model just wrote, worded as fixes for it.
177pub 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
194/// `2/5 done · next: write tests`, or `all 5 done` when nothing is open.
195pub 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
207/// Apply a `todowrite` / `todo` tool argument object.
208///
209/// `merge` defaults to **true**. Returns `None` when `todos` is
210/// missing or not an array of items.
211pub 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
220/// Completed + cancelled count.
221pub fn terminal_count(todos: &[TodoItem]) -> usize {
222    todos.iter().filter(|t| t.status.is_terminal()).count()
223}
224
225/// True when every item is completed or cancelled (and the list is non-empty).
226pub fn all_terminal(todos: &[TodoItem]) -> bool {
227    !todos.is_empty() && todos.iter().all(|t| t.status.is_terminal())
228}
229
230/// True when any item is still pending or in progress.
231pub 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}