atman_runtime/memory/
todo.rs1use 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}