1use std::fs;
44use std::path::{Path, PathBuf};
45
46use serde::Serialize;
47use serde_json::Value;
48
49use crate::error::{Error, Result};
50
51#[derive(Debug, Clone)]
55pub struct TasksRoot {
56 path: PathBuf,
57}
58
59impl TasksRoot {
60 pub fn home() -> Result<Self> {
63 let home = home_dir().ok_or_else(|| Error::Artifacts {
64 message: "could not determine user home directory".to_string(),
65 })?;
66 Ok(Self {
67 path: home.join(".claude").join("tasks"),
68 })
69 }
70
71 pub fn at(path: impl Into<PathBuf>) -> Self {
74 Self { path: path.into() }
75 }
76
77 pub fn path(&self) -> &Path {
79 &self.path
80 }
81
82 pub fn list_sessions(&self) -> Result<Vec<TaskListSummary>> {
86 let entries = match fs::read_dir(&self.path) {
87 Ok(it) => it,
88 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
89 Err(e) => return Err(e.into()),
90 };
91 let mut out = Vec::new();
92 for entry in entries.flatten() {
93 let dir = entry.path();
94 if !dir.is_dir() {
95 continue;
96 }
97 let Some(session_id) = dir.file_name().and_then(|s| s.to_str()) else {
98 continue;
99 };
100 let task_count = task_files(&dir).len();
101 out.push(TaskListSummary {
102 session_id: session_id.to_string(),
103 path: dir,
104 task_count,
105 });
106 }
107 out.sort_by(|a, b| a.session_id.cmp(&b.session_id));
108 Ok(out)
109 }
110
111 pub fn list(&self, session_id: &str) -> Result<Vec<Task>> {
116 let dir = self.path.join(session_id);
117 let mut files = task_files(&dir);
118 files.sort_by_key(|p| {
119 let stem = p
120 .file_stem()
121 .and_then(|s| s.to_str())
122 .unwrap_or_default()
123 .to_string();
124 (stem.parse::<u64>().unwrap_or(u64::MAX), stem)
125 });
126 let mut out = Vec::new();
127 for path in files {
128 match parse_task_file(&path) {
129 Ok(task) => out.push(task),
130 Err(e) => tracing::warn!(?path, "skipping task file: {e}"),
131 }
132 }
133 Ok(out)
134 }
135}
136
137#[derive(Debug, Clone, Serialize)]
140pub struct TaskListSummary {
141 pub session_id: String,
143 pub path: PathBuf,
145 pub task_count: usize,
147}
148
149#[derive(Debug, Clone, Serialize)]
151pub struct Task {
152 pub id: Option<String>,
154 pub subject: Option<String>,
156 pub description: Option<String>,
158 pub active_form: Option<String>,
160 pub status: Option<String>,
163 pub blocks: Option<Vec<String>>,
165 pub blocked_by: Option<Vec<String>>,
167 pub file_path: PathBuf,
169 pub rest: serde_json::Map<String, Value>,
172}
173
174fn task_files(dir: &Path) -> Vec<PathBuf> {
177 let mut out = Vec::new();
178 if let Ok(entries) = fs::read_dir(dir) {
179 for entry in entries.flatten() {
180 let path = entry.path();
181 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
182 out.push(path);
183 }
184 }
185 }
186 out
187}
188
189fn parse_task_file(path: &Path) -> Result<Task> {
190 let content = fs::read_to_string(path)?;
191 let value: Value = serde_json::from_str(&content).map_err(|e| Error::Artifacts {
192 message: format!("task file {} is not valid JSON: {e}", path.display()),
193 })?;
194 let mut rest = match value {
195 Value::Object(map) => map,
196 _ => {
197 return Err(Error::Artifacts {
198 message: format!("task file {} is not a JSON object", path.display()),
199 });
200 }
201 };
202 Ok(Task {
203 id: take_string(&mut rest, "id"),
204 subject: take_string(&mut rest, "subject"),
205 description: take_string(&mut rest, "description"),
206 active_form: take_string(&mut rest, "activeForm"),
207 status: take_string(&mut rest, "status"),
208 blocks: take_string_array(&mut rest, "blocks"),
209 blocked_by: take_string_array(&mut rest, "blockedBy"),
210 file_path: path.to_path_buf(),
211 rest,
212 })
213}
214
215fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
218 match map.remove(key) {
219 Some(Value::String(s)) => Some(s),
220 Some(other) => {
221 map.insert(key.to_string(), other);
222 None
223 }
224 None => None,
225 }
226}
227
228fn take_string_array(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<Vec<String>> {
231 match map.remove(key) {
232 Some(Value::Array(arr)) if arr.iter().all(Value::is_string) => Some(
233 arr.into_iter()
234 .filter_map(|v| match v {
235 Value::String(s) => Some(s),
236 _ => None,
237 })
238 .collect(),
239 ),
240 Some(other) => {
241 map.insert(key.to_string(), other);
242 None
243 }
244 None => None,
245 }
246}
247
248fn home_dir() -> Option<PathBuf> {
249 if let Ok(h) = std::env::var("HOME")
250 && !h.is_empty()
251 {
252 return Some(PathBuf::from(h));
253 }
254 if let Ok(h) = std::env::var("USERPROFILE")
255 && !h.is_empty()
256 {
257 return Some(PathBuf::from(h));
258 }
259 None
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 fn write_task(root: &Path, session: &str, stem: &str, contents: &str) {
267 let dir = root.join(session);
268 fs::create_dir_all(&dir).unwrap();
269 fs::write(dir.join(format!("{stem}.json")), contents).unwrap();
270 }
271
272 fn fixture_root() -> tempfile::TempDir {
273 let tmp = tempfile::tempdir().expect("tempdir");
274 write_task(
275 tmp.path(),
276 "session-x",
277 "1",
278 r#"{"id":"1","subject":"First","description":"d1","activeForm":"Doing first","status":"completed","blocks":["2"],"blockedBy":[],"futureField":7}"#,
279 );
280 write_task(
281 tmp.path(),
282 "session-x",
283 "10",
284 r#"{"id":"10","subject":"Tenth","status":"pending","blocks":"not-an-array"}"#,
285 );
286 write_task(
287 tmp.path(),
288 "session-x",
289 "2",
290 r#"{"id":"2","subject":"Second"}"#,
291 );
292 write_task(tmp.path(), "session-y", "1", r#"NOT JSON"#);
293 tmp
294 }
295
296 #[test]
297 fn list_sessions_counts_task_files() {
298 let tmp = fixture_root();
299 let root = TasksRoot::at(tmp.path());
300 let sessions = root.list_sessions().expect("list");
301 let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
302 assert_eq!(ids, ["session-x", "session-y"]);
303 assert_eq!(sessions[0].task_count, 3);
304 }
305
306 #[test]
307 fn list_sorts_numerically_and_parses_fields() {
308 let tmp = fixture_root();
309 let root = TasksRoot::at(tmp.path());
310 let tasks = root.list("session-x").expect("list");
311 let ids: Vec<Option<&str>> = tasks.iter().map(|t| t.id.as_deref()).collect();
312 assert_eq!(ids, [Some("1"), Some("2"), Some("10")]);
314 let first = &tasks[0];
315 assert_eq!(first.subject.as_deref(), Some("First"));
316 assert_eq!(first.active_form.as_deref(), Some("Doing first"));
317 assert_eq!(first.status.as_deref(), Some("completed"));
318 assert_eq!(first.blocks.as_deref(), Some(["2".to_string()].as_slice()));
319 assert_eq!(first.blocked_by.as_deref(), Some([].as_slice()));
320 assert_eq!(first.rest["futureField"], 7);
321 }
322
323 #[test]
324 fn mistyped_array_stays_in_rest() {
325 let tmp = fixture_root();
326 let root = TasksRoot::at(tmp.path());
327 let tasks = root.list("session-x").expect("list");
328 let tenth = tasks
329 .iter()
330 .find(|t| t.id.as_deref() == Some("10"))
331 .unwrap();
332 assert_eq!(tenth.blocks, None);
333 assert_eq!(tenth.rest["blocks"], "not-an-array");
334 }
335
336 #[test]
337 fn malformed_files_are_skipped() {
338 let tmp = fixture_root();
339 let root = TasksRoot::at(tmp.path());
340 assert!(root.list("session-y").expect("ok").is_empty());
341 }
342
343 #[test]
344 fn missing_root_and_unknown_session_read_empty() {
345 let tmp = tempfile::tempdir().unwrap();
346 let root = TasksRoot::at(tmp.path().join("does-not-exist"));
347 assert!(root.list_sessions().expect("ok").is_empty());
348 assert!(root.list("nope").expect("ok").is_empty());
349 }
350}