1use crate::context::file_system::FileSystemOperations;
2use crate::storage::XdgDirectories;
3use crate::task::{Task, TaskStatus};
4use std::path::PathBuf;
5use std::sync::Arc;
6use tokio::sync::Mutex;
7
8#[async_trait::async_trait]
10pub trait TaskStorage: Send + Sync {
11 async fn add_task(&self, task: Task) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
12 #[allow(dead_code)] async fn get_task(
14 &self,
15 id: &str,
16 ) -> Result<Option<Task>, Box<dyn std::error::Error + Send + Sync>>;
17 async fn list_tasks(&self) -> Result<Vec<Task>, Box<dyn std::error::Error + Send + Sync>>;
18 async fn update_task(&self, task: Task)
19 -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
20 async fn delete_task(&self, id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
21 async fn delete_tasks_by_status(
22 &self,
23 statuses: Vec<TaskStatus>,
24 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>>;
25}
26
27pub struct JsonTaskStorage {
29 file_path: PathBuf,
30 lock: Arc<Mutex<()>>,
31 file_system: Arc<dyn FileSystemOperations>,
32 #[allow(dead_code)]
33 xdg_directories: Arc<XdgDirectories>,
34}
35
36impl JsonTaskStorage {
37 pub fn new(
38 xdg_directories: Arc<XdgDirectories>,
39 file_system: Arc<dyn FileSystemOperations>,
40 ) -> Self {
41 let file_path = xdg_directories.tasks_file();
42
43 Self {
44 file_path,
45 lock: Arc::new(Mutex::new(())),
46 file_system,
47 xdg_directories,
48 }
49 }
50
51 async fn read_tasks(&self) -> Result<Vec<Task>, Box<dyn std::error::Error + Send + Sync>> {
52 if !self
53 .file_system
54 .exists(&self.file_path)
55 .await
56 .map_err(|e| e.to_string())?
57 {
58 return Ok(Vec::new());
59 }
60
61 let contents = self
62 .file_system
63 .read_file(&self.file_path)
64 .await
65 .map_err(|e| e.to_string())?;
66 let tasks: Vec<Task> = serde_json::from_str(&contents)?;
67 Ok(tasks)
68 }
69
70 async fn write_tasks(
71 &self,
72 tasks: &[Task],
73 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
74 let contents = serde_json::to_string_pretty(tasks)?;
75 self.file_system
76 .write_file(&self.file_path, &contents)
77 .await
78 .map_err(|e| e.to_string())?;
79 Ok(())
80 }
81}
82
83#[async_trait::async_trait]
84impl TaskStorage for JsonTaskStorage {
85 async fn add_task(&self, task: Task) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
86 let _lock = self.lock.lock().await;
87
88 let mut tasks = self.read_tasks().await?;
89 tasks.push(task);
90 self.write_tasks(&tasks).await?;
91
92 Ok(())
93 }
94
95 async fn get_task(
96 &self,
97 id: &str,
98 ) -> Result<Option<Task>, Box<dyn std::error::Error + Send + Sync>> {
99 let tasks = self.read_tasks().await?;
100 Ok(tasks.into_iter().find(|t| t.id == id))
101 }
102
103 async fn list_tasks(&self) -> Result<Vec<Task>, Box<dyn std::error::Error + Send + Sync>> {
104 self.read_tasks().await
105 }
106
107 async fn update_task(
108 &self,
109 task: Task,
110 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
111 let _lock = self.lock.lock().await;
112
113 let mut tasks = self.read_tasks().await?;
114 if let Some(index) = tasks.iter().position(|t| t.id == task.id) {
115 tasks[index] = task;
116 self.write_tasks(&tasks).await?;
117 Ok(())
118 } else {
119 Err("Task not found".into())
120 }
121 }
122
123 async fn delete_task(&self, id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
124 let _lock = self.lock.lock().await;
125
126 let mut tasks = self.read_tasks().await?;
127 if let Some(index) = tasks.iter().position(|t| t.id == id) {
128 tasks.remove(index);
129 self.write_tasks(&tasks).await?;
130 Ok(())
131 } else {
132 Err("Task not found".into())
133 }
134 }
135
136 async fn delete_tasks_by_status(
137 &self,
138 statuses: Vec<TaskStatus>,
139 ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
140 let _lock = self.lock.lock().await;
141
142 let mut tasks = self.read_tasks().await?;
143 let original_count = tasks.len();
144 tasks.retain(|t| !statuses.contains(&t.status));
145 let deleted_count = original_count - tasks.len();
146 self.write_tasks(&tasks).await?;
147 Ok(deleted_count)
148 }
149}
150
151pub fn get_task_storage(
153 xdg_directories: Arc<XdgDirectories>,
154 file_system: Arc<dyn FileSystemOperations>,
155) -> Box<dyn TaskStorage> {
156 let storage = JsonTaskStorage::new(xdg_directories, file_system);
157 Box::new(storage)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::context::file_system::DefaultFileSystem;
164 use crate::task::Task;
165 use tempfile::TempDir;
166
167 fn create_test_xdg_directories(temp_dir: &TempDir) -> Arc<XdgDirectories> {
168 unsafe {
169 std::env::set_var("XDG_DATA_HOME", temp_dir.path().join("data"));
170 }
171 unsafe {
172 std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path().join("runtime"));
173 }
174 let xdg = XdgDirectories::new().unwrap();
175 xdg.ensure_directories().unwrap();
176 Arc::new(xdg)
177 }
178
179 #[tokio::test]
180 async fn test_json_task_storage() {
181 let temp_dir = TempDir::new().unwrap();
182 let file_system = Arc::new(DefaultFileSystem);
183 let xdg_directories = create_test_xdg_directories(&temp_dir);
184 let storage = JsonTaskStorage::new(xdg_directories.clone(), file_system);
185
186 let task = Task::new(
188 "2025-06-26-0900-feature-test-task".to_string(),
189 temp_dir.path().to_path_buf(),
190 "test-task".to_string(),
191 "feature".to_string(),
192 "instructions.md".to_string(),
193 "claude-code".to_string(),
194 30,
195 "tsk/test-task".to_string(),
196 "abc123".to_string(),
197 "default".to_string(),
198 "default".to_string(),
199 chrono::Local::now(),
200 temp_dir.path().to_path_buf(),
201 );
202
203 storage.add_task(task.clone()).await.unwrap();
204
205 let retrieved = storage.get_task(&task.id).await.unwrap();
207 assert!(retrieved.is_some());
208 assert_eq!(retrieved.unwrap().name, "test-task");
209
210 let tasks = storage.list_tasks().await.unwrap();
212 assert_eq!(tasks.len(), 1);
213
214 let mut updated_task = task.clone();
216 updated_task.status = TaskStatus::Running;
217 storage.update_task(updated_task).await.unwrap();
218
219 let retrieved = storage.get_task(&task.id).await.unwrap().unwrap();
220 assert_eq!(retrieved.status, TaskStatus::Running);
221
222 storage.delete_task(&task.id).await.unwrap();
224 let retrieved = storage.get_task(&task.id).await.unwrap();
225 assert!(retrieved.is_none());
226
227 let task1 = Task::new(
229 "2025-06-26-0901-feature-task1".to_string(),
230 temp_dir.path().to_path_buf(),
231 "task1".to_string(),
232 "feature".to_string(),
233 "instructions.md".to_string(),
234 "claude-code".to_string(),
235 30,
236 "tsk/task1".to_string(),
237 "abc123".to_string(),
238 "default".to_string(),
239 "default".to_string(),
240 chrono::Local::now(),
241 temp_dir.path().to_path_buf(),
242 );
243 let mut task2 = Task::new(
244 "2025-06-26-0902-bug-fix-task2".to_string(),
245 temp_dir.path().to_path_buf(),
246 "task2".to_string(),
247 "bug-fix".to_string(),
248 "instructions.md".to_string(),
249 "claude-code".to_string(),
250 30,
251 "tsk/task2".to_string(),
252 "abc123".to_string(),
253 "default".to_string(),
254 "default".to_string(),
255 chrono::Local::now(),
256 temp_dir.path().to_path_buf(),
257 );
258 task2.status = TaskStatus::Complete;
259 let mut task3 = Task::new(
260 "2025-06-26-0903-refactor-task3".to_string(),
261 temp_dir.path().to_path_buf(),
262 "task3".to_string(),
263 "refactor".to_string(),
264 "instructions.md".to_string(),
265 "claude-code".to_string(),
266 30,
267 "tsk/task3".to_string(),
268 "abc123".to_string(),
269 "default".to_string(),
270 "default".to_string(),
271 chrono::Local::now(),
272 temp_dir.path().to_path_buf(),
273 );
274 task3.status = TaskStatus::Failed;
275
276 storage.add_task(task1.clone()).await.unwrap();
277 storage.add_task(task2.clone()).await.unwrap();
278 storage.add_task(task3.clone()).await.unwrap();
279
280 let deleted_count = storage
282 .delete_tasks_by_status(vec![TaskStatus::Complete, TaskStatus::Failed])
283 .await
284 .unwrap();
285 assert_eq!(deleted_count, 2);
286
287 let remaining_tasks = storage.list_tasks().await.unwrap();
289 assert_eq!(remaining_tasks.len(), 1);
290 assert_eq!(remaining_tasks[0].status, TaskStatus::Queued);
291 }
292}