use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct TasksRoot {
path: PathBuf,
}
impl TasksRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::Artifacts {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("tasks"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list_sessions(&self) -> Result<Vec<TaskListSummary>> {
let entries = match fs::read_dir(&self.path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let Some(session_id) = dir.file_name().and_then(|s| s.to_str()) else {
continue;
};
let task_count = task_files(&dir).len();
out.push(TaskListSummary {
session_id: session_id.to_string(),
path: dir,
task_count,
});
}
out.sort_by(|a, b| a.session_id.cmp(&b.session_id));
Ok(out)
}
pub fn list(&self, session_id: &str) -> Result<Vec<Task>> {
let dir = self.path.join(session_id);
let mut files = task_files(&dir);
files.sort_by_key(|p| {
let stem = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
(stem.parse::<u64>().unwrap_or(u64::MAX), stem)
});
let mut out = Vec::new();
for path in files {
match parse_task_file(&path) {
Ok(task) => out.push(task),
Err(e) => tracing::warn!(?path, "skipping task file: {e}"),
}
}
Ok(out)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskListSummary {
pub session_id: String,
pub path: PathBuf,
pub task_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct Task {
pub id: Option<String>,
pub subject: Option<String>,
pub description: Option<String>,
pub active_form: Option<String>,
pub status: Option<String>,
pub blocks: Option<Vec<String>>,
pub blocked_by: Option<Vec<String>>,
pub file_path: PathBuf,
pub rest: serde_json::Map<String, Value>,
}
fn task_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
out.push(path);
}
}
}
out
}
fn parse_task_file(path: &Path) -> Result<Task> {
let content = fs::read_to_string(path)?;
let value: Value = serde_json::from_str(&content).map_err(|e| Error::Artifacts {
message: format!("task file {} is not valid JSON: {e}", path.display()),
})?;
let mut rest = match value {
Value::Object(map) => map,
_ => {
return Err(Error::Artifacts {
message: format!("task file {} is not a JSON object", path.display()),
});
}
};
Ok(Task {
id: take_string(&mut rest, "id"),
subject: take_string(&mut rest, "subject"),
description: take_string(&mut rest, "description"),
active_form: take_string(&mut rest, "activeForm"),
status: take_string(&mut rest, "status"),
blocks: take_string_array(&mut rest, "blocks"),
blocked_by: take_string_array(&mut rest, "blockedBy"),
file_path: path.to_path_buf(),
rest,
})
}
fn take_string(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
match map.remove(key) {
Some(Value::String(s)) => Some(s),
Some(other) => {
map.insert(key.to_string(), other);
None
}
None => None,
}
}
fn take_string_array(map: &mut serde_json::Map<String, Value>, key: &str) -> Option<Vec<String>> {
match map.remove(key) {
Some(Value::Array(arr)) if arr.iter().all(Value::is_string) => Some(
arr.into_iter()
.filter_map(|v| match v {
Value::String(s) => Some(s),
_ => None,
})
.collect(),
),
Some(other) => {
map.insert(key.to_string(), other);
None
}
None => None,
}
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write_task(root: &Path, session: &str, stem: &str, contents: &str) {
let dir = root.join(session);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{stem}.json")), contents).unwrap();
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
write_task(
tmp.path(),
"session-x",
"1",
r#"{"id":"1","subject":"First","description":"d1","activeForm":"Doing first","status":"completed","blocks":["2"],"blockedBy":[],"futureField":7}"#,
);
write_task(
tmp.path(),
"session-x",
"10",
r#"{"id":"10","subject":"Tenth","status":"pending","blocks":"not-an-array"}"#,
);
write_task(
tmp.path(),
"session-x",
"2",
r#"{"id":"2","subject":"Second"}"#,
);
write_task(tmp.path(), "session-y", "1", r#"NOT JSON"#);
tmp
}
#[test]
fn list_sessions_counts_task_files() {
let tmp = fixture_root();
let root = TasksRoot::at(tmp.path());
let sessions = root.list_sessions().expect("list");
let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
assert_eq!(ids, ["session-x", "session-y"]);
assert_eq!(sessions[0].task_count, 3);
}
#[test]
fn list_sorts_numerically_and_parses_fields() {
let tmp = fixture_root();
let root = TasksRoot::at(tmp.path());
let tasks = root.list("session-x").expect("list");
let ids: Vec<Option<&str>> = tasks.iter().map(|t| t.id.as_deref()).collect();
assert_eq!(ids, [Some("1"), Some("2"), Some("10")]);
let first = &tasks[0];
assert_eq!(first.subject.as_deref(), Some("First"));
assert_eq!(first.active_form.as_deref(), Some("Doing first"));
assert_eq!(first.status.as_deref(), Some("completed"));
assert_eq!(first.blocks.as_deref(), Some(["2".to_string()].as_slice()));
assert_eq!(first.blocked_by.as_deref(), Some([].as_slice()));
assert_eq!(first.rest["futureField"], 7);
}
#[test]
fn mistyped_array_stays_in_rest() {
let tmp = fixture_root();
let root = TasksRoot::at(tmp.path());
let tasks = root.list("session-x").expect("list");
let tenth = tasks
.iter()
.find(|t| t.id.as_deref() == Some("10"))
.unwrap();
assert_eq!(tenth.blocks, None);
assert_eq!(tenth.rest["blocks"], "not-an-array");
}
#[test]
fn malformed_files_are_skipped() {
let tmp = fixture_root();
let root = TasksRoot::at(tmp.path());
assert!(root.list("session-y").expect("ok").is_empty());
}
#[test]
fn missing_root_and_unknown_session_read_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = TasksRoot::at(tmp.path().join("does-not-exist"));
assert!(root.list_sessions().expect("ok").is_empty());
assert!(root.list("nope").expect("ok").is_empty());
}
}