Skip to main content

claude_wrapper/
tasks.rs

1//! Read-side access to Claude Code's on-disk **task-tool** state.
2//!
3//! The task tools (TaskCreate / TaskUpdate) persist one JSON file
4//! per task item under `~/.claude/tasks/<session-id>/<n>.json`:
5//!
6//! ```json
7//! {
8//!   "id": "1",
9//!   "subject": "Untrack the local config artifact",
10//!   "description": "...",
11//!   "activeForm": "Untracking the local config artifact",
12//!   "status": "completed",
13//!   "blocks": [],
14//!   "blockedBy": []
15//! }
16//! ```
17//!
18//! This module lists and parses them; it is read-only on purpose,
19//! like the other introspection modules. The layout is undocumented
20//! Claude Code internal state (observed against CLI 2.1.219) and
21//! can change across CLI versions, so parsing is defensive: every
22//! typed field is `Option`-shaped, a field with an unexpected JSON
23//! type stays in [`Task::rest`], and unknown fields land there too.
24//!
25//! - [`TasksRoot::list_sessions`] -- which sessions have task
26//!   lists, with counts.
27//! - [`TasksRoot::list`] -- one session's tasks in numeric file
28//!   order.
29//!
30//! # Example
31//!
32//! ```no_run
33//! use claude_wrapper::tasks::TasksRoot;
34//!
35//! # fn example() -> claude_wrapper::Result<()> {
36//! let root = TasksRoot::home()?;
37//! for list in root.list_sessions()? {
38//!     println!("{}: {} tasks", list.session_id, list.task_count);
39//! }
40//! # Ok(()) }
41//! ```
42
43use std::fs;
44use std::path::{Path, PathBuf};
45
46use serde::Serialize;
47use serde_json::Value;
48
49use crate::error::{Error, Result};
50
51/// Root directory of Claude Code's task-tool state. Defaults to
52/// `~/.claude/tasks`; override with [`TasksRoot::at`] for tests or
53/// non-default installs.
54#[derive(Debug, Clone)]
55pub struct TasksRoot {
56    path: PathBuf,
57}
58
59impl TasksRoot {
60    /// Resolve the default `~/.claude/tasks`. Errors if `$HOME`
61    /// (or the platform-specific user home) cannot be determined.
62    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    /// Use a specific path as the tasks root. Useful for tests
72    /// (point at a tempdir) and for non-default installs.
73    pub fn at(path: impl Into<PathBuf>) -> Self {
74        Self { path: path.into() }
75    }
76
77    /// The configured root directory.
78    pub fn path(&self) -> &Path {
79        &self.path
80    }
81
82    /// List every session directory at the root, sorted by session
83    /// id, with the number of task files in each. A missing root
84    /// returns an empty vec.
85    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    /// List one session's tasks, sorted numerically by file stem
112    /// (`1.json`, `2.json`, ..., `10.json`). A session without a
113    /// task directory (or an unknown session id) returns an empty
114    /// vec. Malformed files are skipped with a tracing warning.
115    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/// One session that has task-tool state, returned by
138/// [`TasksRoot::list_sessions`].
139#[derive(Debug, Clone, Serialize)]
140pub struct TaskListSummary {
141    /// The session id (the directory name).
142    pub session_id: String,
143    /// Absolute path of the session's task directory.
144    pub path: PathBuf,
145    /// Number of task files in the directory.
146    pub task_count: usize,
147}
148
149/// One task item parsed from `<n>.json`.
150#[derive(Debug, Clone, Serialize)]
151pub struct Task {
152    /// Task id, when present. Usually matches the file stem.
153    pub id: Option<String>,
154    /// Short imperative subject, when present.
155    pub subject: Option<String>,
156    /// Full description, when present.
157    pub description: Option<String>,
158    /// Present-continuous display form, when present.
159    pub active_form: Option<String>,
160    /// Status (`pending`, `in_progress`, `completed`, or anything
161    /// future), carried as a plain string.
162    pub status: Option<String>,
163    /// Ids of tasks this one blocks, when present and well-formed.
164    pub blocks: Option<Vec<String>>,
165    /// Ids of tasks blocking this one, when present and well-formed.
166    pub blocked_by: Option<Vec<String>>,
167    /// Absolute path to the source `.json`.
168    pub file_path: PathBuf,
169    /// Any additional fields, keyed as Claude Code wrote them. A
170    /// typed field with an unexpected JSON type also lands here.
171    pub rest: serde_json::Map<String, Value>,
172}
173
174/// Task item files in a directory: direct children matching
175/// `*.json`. Missing or unreadable directories yield an empty list.
176fn 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
215/// Remove `key` when it holds a string; any other type stays in the
216/// map so it surfaces through `rest`.
217fn 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
228/// Remove `key` when it holds an array of strings; any other shape
229/// stays in the map so it surfaces through `rest`.
230fn 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        // Numeric order: 1, 2, 10 (not lexical 1, 10, 2).
313        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}