#[derive(Debug, Clone)]
pub(crate) struct TaskRow {
pub(crate) id: String,
pub(crate) subject: String,
pub(crate) status: String,
pub(crate) blocked_by: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct TaskStore {
pub(crate) dir: String,
pub(crate) via: &'static str,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TasksReport {
pub(crate) open: Vec<TaskRow>,
pub(crate) completed: usize,
pub(crate) found: bool,
pub(crate) stores: Vec<TaskStore>,
}
const TEAM_START_WINDOW_MS: i64 = 5000;
pub(crate) fn tasks_report_with(
owner_id: &str,
cleared_root: Option<&str>,
started_at_ms: Option<i64>,
) -> TasksReport {
let mut report = TasksReport::default();
let Ok(home) = crate::path::claude_home() else {
return report;
};
let tasks_root = home.join("tasks");
let tiers = [
id_forms(owner_id, "own id"),
cleared_root
.filter(|r| *r != owner_id)
.map(|r| id_forms(r, "cleared_from root"))
.unwrap_or_default(),
started_at_ms
.map(|t| team_candidates(&home, t))
.unwrap_or_default(),
];
for tier in tiers {
for (name, via) in tier {
read_store(&tasks_root.join(&name), &name, via, &mut report);
}
if report.found {
break;
}
}
report.open.sort_by(|a, b| {
let rank = |t: &TaskRow| usize::from(t.status != "in_progress");
(rank(a), numeric_id(&a.id)).cmp(&(rank(b), numeric_id(&b.id)))
});
report
}
fn id_forms(id: &str, via: &'static str) -> Vec<(String, &'static str)> {
let mut out = vec![(id.to_string(), via)];
if let Some(prefix) = id.get(..8) {
out.push((format!("session-{prefix}"), via));
}
out
}
pub(crate) fn team_candidates(
home: &std::path::Path,
started_at_ms: i64,
) -> Vec<(String, &'static str)> {
let Ok(entries) = std::fs::read_dir(home.join("teams")) else {
return Vec::new();
};
let mut out: Vec<(String, &'static str)> = Vec::new();
for entry in entries.flatten() {
let Ok(raw) = std::fs::read_to_string(entry.path().join("config.json")) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let (Some(created), Some(name)) = (
v.get("createdAt").and_then(serde_json::Value::as_i64),
v.get("name").and_then(serde_json::Value::as_str),
) else {
continue;
};
if (created - started_at_ms).abs() <= TEAM_START_WINDOW_MS {
out.push((name.to_string(), "team file"));
}
}
out.sort();
out
}
fn read_store(dir: &std::path::Path, name: &str, via: &'static str, report: &mut TasksReport) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
report.found = true;
report.stores.push(TaskStore {
dir: name.to_string(),
via,
});
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(raw) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
continue;
};
let status = v
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("(no status)")
.to_string();
if status == "completed" {
report.completed += 1;
continue;
}
report.open.push(TaskRow {
id: json_id(v.get("id")),
subject: v
.get("subject")
.and_then(serde_json::Value::as_str)
.unwrap_or("(no subject)")
.to_string(),
status,
blocked_by: v
.get("blockedBy")
.and_then(serde_json::Value::as_array)
.map(|a| a.iter().map(|x| json_id(Some(x))).collect())
.unwrap_or_default(),
});
}
}
fn json_id(v: Option<&serde_json::Value>) -> String {
match v {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Number(n)) => n.to_string(),
_ => "?".to_string(),
}
}
fn numeric_id(id: &str) -> u64 {
id.parse().unwrap_or(u64::MAX)
}