Skip to main content

atman_runtime/memory/
todo.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::RuntimeError;
6use crate::memory::{MemoryId, append_jsonl, read_jsonl};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum TodoStatus {
11    Pending,
12    InProgress,
13    Done,
14    Cancelled,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Todo {
19    pub id: MemoryId,
20    #[serde(rename = "where")]
21    pub where_: String,
22    pub why: String,
23    pub how: String,
24    pub expected_result: String,
25    pub status: TodoStatus,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "op", rename_all = "snake_case")]
30pub enum TodoEntry {
31    Add(Todo),
32    Update { id: MemoryId, status: TodoStatus },
33    Delete { id: MemoryId },
34}
35
36pub struct TodoStore {
37    path: PathBuf,
38    notify: Option<tokio::sync::watch::Sender<Vec<Todo>>>,
39}
40
41impl TodoStore {
42    pub fn at(session_dir: impl AsRef<Path>) -> Self {
43        Self {
44            path: session_dir.as_ref().join("todos.jsonl"),
45            notify: None,
46        }
47    }
48
49    pub fn with_notify(mut self, tx: tokio::sync::watch::Sender<Vec<Todo>>) -> Self {
50        self.notify = Some(tx);
51        self
52    }
53
54    async fn notify_if_needed(&self) {
55        if let Some(tx) = &self.notify {
56            if let Ok(list) = self.list().await {
57                let _ = tx.send(list);
58            }
59        }
60    }
61
62    pub async fn add(&self, todo: Todo) -> Result<MemoryId, RuntimeError> {
63        let id = todo.id.clone();
64        append_jsonl(&self.path, &TodoEntry::Add(todo)).await?;
65        self.notify_if_needed().await;
66        Ok(id)
67    }
68
69    pub async fn set_status(&self, id: &MemoryId, status: TodoStatus) -> Result<(), RuntimeError> {
70        append_jsonl(
71            &self.path,
72            &TodoEntry::Update {
73                id: id.clone(),
74                status,
75            },
76        )
77        .await?;
78        self.notify_if_needed().await;
79        Ok(())
80    }
81
82    pub async fn delete(&self, id: &MemoryId) -> Result<(), RuntimeError> {
83        append_jsonl(&self.path, &TodoEntry::Delete { id: id.clone() }).await?;
84        self.notify_if_needed().await;
85        Ok(())
86    }
87
88    pub async fn list(&self) -> Result<Vec<Todo>, RuntimeError> {
89        let entries: Vec<TodoEntry> = read_jsonl(&self.path).await?;
90        let mut items: Vec<Todo> = Vec::new();
91        for entry in entries {
92            match entry {
93                TodoEntry::Add(todo) => items.push(todo),
94                TodoEntry::Update { id, status } => {
95                    if let Some(existing) = items.iter_mut().find(|t| t.id == id) {
96                        existing.status = status;
97                    }
98                }
99                TodoEntry::Delete { id } => {
100                    items.retain(|t| t.id != id);
101                }
102            }
103        }
104        Ok(items)
105    }
106
107    pub fn path(&self) -> &Path {
108        &self.path
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use tempfile::TempDir;
116
117    fn sample_todo(name: &str) -> Todo {
118        Todo {
119            id: MemoryId::now(),
120            where_: format!("src/{name}.rs"),
121            why: "for W5 spec".into(),
122            how: "add helper".into(),
123            expected_result: "test passes".into(),
124            status: TodoStatus::Pending,
125        }
126    }
127
128    #[tokio::test]
129    async fn add_then_list_returns_todo() {
130        let dir = TempDir::new().unwrap();
131        let store = TodoStore::at(dir.path());
132        let id = store.add(sample_todo("foo")).await.unwrap();
133        let items = store.list().await.unwrap();
134        assert_eq!(items.len(), 1);
135        assert_eq!(items[0].id, id);
136        assert!(matches!(items[0].status, TodoStatus::Pending));
137    }
138
139    #[tokio::test]
140    async fn set_status_replays_over_add() {
141        let dir = TempDir::new().unwrap();
142        let store = TodoStore::at(dir.path());
143        let id = store.add(sample_todo("bar")).await.unwrap();
144        store.set_status(&id, TodoStatus::Done).await.unwrap();
145        let items = store.list().await.unwrap();
146        assert_eq!(items.len(), 1);
147        assert!(matches!(items[0].status, TodoStatus::Done));
148    }
149
150    #[tokio::test]
151    async fn empty_dir_lists_empty_vec() {
152        let dir = TempDir::new().unwrap();
153        let store = TodoStore::at(dir.path());
154        assert!(store.list().await.unwrap().is_empty());
155    }
156
157    #[tokio::test]
158    async fn multiple_updates_replay_in_order() {
159        let dir = TempDir::new().unwrap();
160        let store = TodoStore::at(dir.path());
161        let id = store.add(sample_todo("baz")).await.unwrap();
162        store.set_status(&id, TodoStatus::InProgress).await.unwrap();
163        store.set_status(&id, TodoStatus::Done).await.unwrap();
164        let items = store.list().await.unwrap();
165        assert!(matches!(items[0].status, TodoStatus::Done));
166    }
167}