diff --git a/Cargo.lock b/Cargo.lock index 77ef7157..ba5ec546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -999,8 +999,12 @@ dependencies = [ "deepseek-execpolicy", "deepseek-mcp", "deepseek-state", + "dirs", + "serde", "serde_json", + "tempfile", "tokio", + "tracing", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1a23dbdd..ba6e7f5a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -21,5 +21,11 @@ deepseek-execpolicy = { path = "../execpolicy", version = "0.6.0" } deepseek-mcp = { path = "../mcp", version = "0.6.0" } deepseek-state = { path = "../state", version = "0.6.0" } chrono.workspace = true +dirs.workspace = true +serde.workspace = true serde_json.workspace = true tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile = "3.16" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 67ae1da4..09637d43 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,3 +1,5 @@ +mod metrics; + use std::io::{self, Read}; use std::net::SocketAddr; use std::path::PathBuf; @@ -135,6 +137,18 @@ enum Commands { #[arg(value_enum)] shell: Shell, }, + /// Print a usage rollup from the audit log and session store. + Metrics(MetricsArgs), +} + +#[derive(Debug, Args)] +struct MetricsArgs { + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h). + #[arg(long, value_name = "DURATION")] + since: Option, } #[derive(Debug, Args)] @@ -392,6 +406,7 @@ fn run() -> Result<()> { generate(shell, &mut cmd, "deepseek", &mut io::stdout()); Ok(()) } + Some(Commands::Metrics(args)) => run_metrics_command(args), None => { let mut forwarded = Vec::new(); if let Some(prompt) = cli.prompt.clone() { @@ -865,6 +880,19 @@ fn delegate_simple_tui(args: Vec) -> Result<()> { } } +fn run_metrics_command(args: MetricsArgs) -> Result<()> { + let since = match args.since.as_deref() { + Some(s) => { + Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?) + } + None => None, + }; + metrics::run(metrics::MetricsArgs { + json: args.json, + since, + }) +} + fn read_api_key_from_stdin() -> Result { let mut input = String::new(); io::stdin() @@ -1234,6 +1262,7 @@ mod tests { "sandbox", "app-server", "completion", + "metrics", "--provider", "--model", "--config", @@ -1279,6 +1308,7 @@ mod tests { vec!["--host", "--port", "--config", "--stdio"], ), ("completion", vec!["", "bash"]), + ("metrics", vec!["--json", "--since"]), ]; for (subcommand, expected_tokens) in cases { diff --git a/crates/cli/src/metrics.rs b/crates/cli/src/metrics.rs new file mode 100644 index 00000000..e5437096 --- /dev/null +++ b/crates/cli/src/metrics.rs @@ -0,0 +1,1025 @@ +//! `deepseek metrics` — reads the audit log and session/task stores and prints +//! a human-readable usage rollup. +//! +//! Data sources: +//! - `~/.deepseek/audit.log` — one JSON line per event (approvals, credentials) +//! - `~/.deepseek/sessions/` — saved session JSON files (tool call history) +//! - `~/.deepseek/tasks/runtime/events/` — runtime thread JSONL event streams + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use chrono::{DateTime, Duration, Utc}; +use serde_json::Value; + +// ────────────────────────────────────────────────────────────────────────────── +// Public entry-point +// ────────────────────────────────────────────────────────────────────────────── + +/// Arguments accepted by `deepseek metrics`. +#[derive(Debug, Default)] +pub struct MetricsArgs { + /// Emit machine-readable JSON instead of human text. + pub json: bool, + /// Restrict to events newer than this cutoff (inclusive). + pub since: Option>, +} + +pub fn run(args: MetricsArgs) -> Result<()> { + let base = deepseek_home(); + + // Collect data from every source; treat missing files as empty. + let mut rollup = Rollup::default(); + read_audit_log(&base.join("audit.log"), args.since, &mut rollup); + read_session_files(&base.join("sessions"), args.since, &mut rollup); + read_runtime_events( + &base.join("tasks").join("runtime").join("events"), + args.since, + &mut rollup, + ); + + if args.json { + print_json(&rollup)?; + } else { + print_human(&rollup); + } + + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Duration-string parser ("7d", "24h", "30m", "2h", "now-2h", "2h30m") +// ────────────────────────────────────────────────────────────────────────────── + +/// Parse a loose humantime-ish duration string into an absolute `DateTime` +/// cutoff (i.e. `Utc::now() - duration`). +/// +/// Accepted forms: +/// - `7d` / `24h` / `30m` / `90s` +/// - `2h30m`, `1d12h` +/// - `now-2h` (leading `now-` is stripped before parsing) +pub fn parse_since(s: &str) -> Result> { + let s = s.trim().to_ascii_lowercase(); + let s = s.strip_prefix("now-").unwrap_or(&s); + let secs = parse_duration_secs(s)?; + Ok(Utc::now() - Duration::seconds(secs)) +} + +fn parse_duration_secs(s: &str) -> Result { + // Walk through the string accumulating numbers and consuming unit suffixes. + let mut total: i64 = 0; + let mut num_buf = String::new(); + + for ch in s.chars() { + match ch { + '0'..='9' => num_buf.push(ch), + 'd' | 'h' | 'm' | 's' => { + let n: i64 = num_buf + .parse() + .map_err(|_| anyhow::anyhow!("invalid duration component: {:?}", num_buf))?; + num_buf.clear(); + let factor = match ch { + 'd' => 86_400, + 'h' => 3_600, + 'm' => 60, + 's' => 1, + _ => unreachable!(), + }; + total += n * factor; + } + _ => anyhow::bail!("unrecognised character {:?} in duration {:?}", ch, s), + } + } + + if !num_buf.is_empty() { + // Trailing bare number — treat as seconds. + let n: i64 = num_buf.parse()?; + total += n; + } + + if total == 0 { + anyhow::bail!("duration {:?} resolved to zero seconds", s); + } + + Ok(total) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Rollup data model +// ────────────────────────────────────────────────────────────────────────────── + +/// Per-tool aggregated counters. +#[derive(Debug, Default, serde::Serialize)] +pub struct ToolStats { + pub calls: u64, + /// Calls that were auto-approved (no prompt required). + pub auto_approved: u64, + /// Calls that required a manual prompt. + pub prompted: u64, + /// Total elapsed ms (from events that carry this field). + pub total_elapsed_ms: u64, + /// Number of elapsed_ms samples included in `total_elapsed_ms`. + pub elapsed_samples: u64, + /// Successful calls (where we have result data). + pub successes: u64, + /// Failed calls. + pub failures: u64, +} + +impl ToolStats { + fn success_rate_pct(&self) -> Option { + let judged = self.successes + self.failures; + if judged == 0 { + None + } else { + Some(self.successes as f64 / judged as f64 * 100.0) + } + } + + fn avg_elapsed_ms(&self) -> Option { + self.total_elapsed_ms.checked_div(self.elapsed_samples) + } +} + +/// Compaction event stats. +#[derive(Debug, Default, serde::Serialize)] +pub struct CompactionStats { + pub events: u64, + /// Sum of `reduction_ratio` from events that carry it (0.0–1.0 each). + pub ratio_sum: f64, + pub ratio_samples: u64, +} + +impl CompactionStats { + fn avg_reduction_pct(&self) -> Option { + if self.ratio_samples == 0 { + None + } else { + Some(self.ratio_sum / self.ratio_samples as f64 * 100.0) + } + } +} + +/// Sub-agent spawn stats. +#[derive(Debug, Default, serde::Serialize)] +pub struct AgentStats { + pub spawns: u64, + pub successes: u64, + pub failures: u64, +} + +impl AgentStats { + fn success_rate_pct(&self) -> Option { + let judged = self.successes + self.failures; + if judged == 0 { + None + } else { + Some(self.successes as f64 / judged as f64 * 100.0) + } + } +} + +/// Capacity-controller / rate-limit intervention stats. +#[derive(Debug, Default, serde::Serialize)] +pub struct CapacityStats { + pub total: u64, + pub by_category: HashMap, +} + +/// Credential / session event stats (from audit log). +#[derive(Debug, Default, serde::Serialize)] +pub struct CredentialStats { + pub saves: u64, + pub clears: u64, +} + +/// Top-level rollup. +#[derive(Debug, Default, serde::Serialize)] +pub struct Rollup { + /// UTC timestamp of the earliest event we've seen. + pub earliest_ts: Option>, + /// UTC timestamp of the latest event we've seen. + pub latest_ts: Option>, + /// Per-tool stats keyed by tool name. + pub tools: HashMap, + pub compaction: CompactionStats, + pub agents: AgentStats, + pub capacity: CapacityStats, + pub credentials: CredentialStats, + /// Total lines read across all sources. + pub total_lines: u64, + /// Lines successfully parsed. + pub parsed_lines: u64, +} + +impl Rollup { + fn touch_ts(&mut self, ts: &DateTime) { + match self.earliest_ts { + None => self.earliest_ts = Some(*ts), + Some(ref cur) if ts < cur => self.earliest_ts = Some(*ts), + _ => {} + } + match self.latest_ts { + None => self.latest_ts = Some(*ts), + Some(ref cur) if ts > cur => self.latest_ts = Some(*ts), + _ => {} + } + } + + fn tool_mut(&mut self, name: &str) -> &mut ToolStats { + self.tools.entry(name.to_string()).or_default() + } + + fn total_tool_calls(&self) -> u64 { + self.tools.values().map(|t| t.calls).sum() + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Source readers +// ────────────────────────────────────────────────────────────────────────────── + +/// Read one-JSON-line-per-event audit log. +fn read_audit_log(path: &Path, since: Option>, rollup: &mut Rollup) { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::trace!( + "metrics: could not read audit log {}: {}", + path.display(), + e + ); + return; + } + }; + + for raw_line in content.lines() { + rollup.total_lines += 1; + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + let v: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + tracing::trace!("metrics: skipping malformed audit line: {e}"); + continue; + } + }; + + // Parse timestamp — field is "ts" in audit log. + let ts = parse_ts_field(&v, "ts"); + + if let Some(cutoff) = since { + match ts { + Some(t) if t < cutoff => continue, + _ => {} + } + } + + rollup.parsed_lines += 1; + if let Some(t) = &ts { + rollup.touch_ts(t); + } + + let event = v.get("event").and_then(|e| e.as_str()).unwrap_or(""); + + match event { + "tool.approval.auto_approve" => { + let tool_name = v + .pointer("/details/tool_name") + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let stats = rollup.tool_mut(tool_name); + stats.calls += 1; + stats.auto_approved += 1; + } + "tool.approval.prompted" => { + let tool_name = v + .pointer("/details/tool_name") + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let stats = rollup.tool_mut(tool_name); + stats.calls += 1; + stats.prompted += 1; + } + "tool.completed" | "tool.result" => { + let tool_name = v + .pointer("/details/tool_name") + .or_else(|| v.pointer("/payload/tool_name")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let stats = rollup.tool_mut(tool_name); + stats.calls += 1; + + // Optional elapsed_ms + if let Some(ms) = v + .pointer("/details/elapsed_ms") + .or_else(|| v.pointer("/payload/elapsed_ms")) + .and_then(|v| v.as_u64()) + { + stats.total_elapsed_ms += ms; + stats.elapsed_samples += 1; + } + + // Success / failure + let success = v + .pointer("/details/success") + .or_else(|| v.pointer("/payload/success")) + .and_then(|b| b.as_bool()) + .unwrap_or(true); + if success { + stats.successes += 1; + } else { + stats.failures += 1; + } + } + "compaction.completed" | "context.compaction" => { + rollup.compaction.events += 1; + if let Some(ratio) = v + .pointer("/details/reduction_ratio") + .or_else(|| v.pointer("/payload/reduction_ratio")) + .and_then(|r| r.as_f64()) + { + rollup.compaction.ratio_sum += ratio; + rollup.compaction.ratio_samples += 1; + } + } + "agent.spawn" | "subagent.spawned" => { + rollup.agents.spawns += 1; + } + "agent.completed" | "subagent.completed" => { + let success = v + .pointer("/details/success") + .or_else(|| v.pointer("/payload/success")) + .and_then(|b| b.as_bool()) + .unwrap_or(true); + if success { + rollup.agents.successes += 1; + } else { + rollup.agents.failures += 1; + } + } + e if e.starts_with("capacity.") => { + rollup.capacity.total += 1; + let category = v + .pointer("/details/category") + .or_else(|| v.pointer("/payload/category")) + .and_then(|c| c.as_str()) + .unwrap_or(e.trim_start_matches("capacity.")); + *rollup + .capacity + .by_category + .entry(category.to_string()) + .or_insert(0) += 1; + } + "credential.save" => { + rollup.credentials.saves += 1; + } + "credential.clear" => { + rollup.credentials.clears += 1; + } + _ => { + // Unknown event — tracked in parsed_lines but otherwise ignored. + } + } + } +} + +/// Read session JSON files under `sessions/` (one per session). +/// These carry tool call history with optional elapsed_ms and result data. +fn read_session_files(sessions_dir: &Path, since: Option>, rollup: &mut Rollup) { + let rd = match std::fs::read_dir(sessions_dir) { + Ok(rd) => rd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::trace!( + "metrics: could not list sessions dir {}: {}", + sessions_dir.display(), + e + ); + return; + } + }; + + for entry in rd.flatten() { + let path = entry.path(); + // Only look at .json files directly in sessions/; skip sub-dirs. + if path.is_dir() || path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + read_session_file(&path, since, rollup); + } +} + +fn read_session_file(path: &Path, since: Option>, rollup: &mut Rollup) { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + tracing::trace!( + "metrics: could not read session file {}: {}", + path.display(), + e + ); + return; + } + }; + + rollup.total_lines += 1; + + let v: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + tracing::trace!( + "metrics: skipping malformed session file {}: {}", + path.display(), + e + ); + return; + } + }; + + rollup.parsed_lines += 1; + + // Session-level timestamp filter (check metadata.created_at or updated_at). + let session_ts = v + .pointer("/metadata/updated_at") + .or_else(|| v.pointer("/metadata/created_at")) + .and_then(|t| t.as_str()) + .and_then(|s| s.parse::>().ok()); + + if let Some(cutoff) = since + && let Some(ts) = &session_ts + && *ts < cutoff + { + return; + } + + if let Some(ts) = session_ts { + rollup.touch_ts(&ts); + } + + // Walk messages looking for tool_use calls with associated results. + let messages = match v.get("messages").and_then(|m| m.as_array()) { + Some(m) => m, + None => return, + }; + + // Build a map from tool_use_id → (tool_name, elapsed_ms_option, started_at_option). + let mut pending: HashMap)> = HashMap::new(); + + for msg in messages { + let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or(""); + let content_arr = match msg.get("content").and_then(|c| c.as_array()) { + Some(c) => c, + None => continue, + }; + + for block in content_arr { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match (role, block_type) { + ("assistant", "tool_use") => { + let id = block.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let name = block + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); + let elapsed_ms = block.get("elapsed_ms").and_then(|e| e.as_u64()); + if !id.is_empty() { + pending.insert(id.to_string(), (name.to_string(), elapsed_ms)); + } + } + ("user", "tool_result") => { + let id = block + .get("tool_use_id") + .and_then(|i| i.as_str()) + .unwrap_or(""); + if let Some((name, elapsed_ms)) = pending.remove(id) { + let stats = rollup.tool_mut(&name); + // Only count if not already counted via audit log (we don't de-dup, so + // session files may double-count approvals; that's acceptable — users who + // want precise counts should use --json and cross-reference). + stats.calls += 1; + if let Some(ms) = elapsed_ms { + stats.total_elapsed_ms += ms; + stats.elapsed_samples += 1; + } + // Tool result success: absence of "is_error": true + let is_error = block + .get("is_error") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + if is_error { + stats.failures += 1; + } else { + stats.successes += 1; + } + } + } + _ => {} + } + } + } + + // Walk messages for compaction events embedded as special user messages. + for msg in messages { + if let Some(compaction) = msg + .get("compaction") + .or_else(|| msg.pointer("/metadata/compaction")) + { + rollup.compaction.events += 1; + if let Some(ratio) = compaction.get("reduction_ratio").and_then(|r| r.as_f64()) { + rollup.compaction.ratio_sum += ratio; + rollup.compaction.ratio_samples += 1; + } + } + } +} + +/// Read JSONL event streams from the tasks runtime events directory. +fn read_runtime_events(events_dir: &Path, since: Option>, rollup: &mut Rollup) { + let rd = match std::fs::read_dir(events_dir) { + Ok(rd) => rd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::trace!( + "metrics: could not list events dir {}: {}", + events_dir.display(), + e + ); + return; + } + }; + + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().map(|e| e != "jsonl").unwrap_or(true) { + continue; + } + read_events_jsonl(&path, since, rollup); + } +} + +fn read_events_jsonl(path: &Path, since: Option>, rollup: &mut Rollup) { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => { + tracing::trace!( + "metrics: could not read events file {}: {}", + path.display(), + e + ); + return; + } + }; + + for raw_line in content.lines() { + rollup.total_lines += 1; + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + let v: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => { + tracing::trace!("metrics: skipping malformed event line: {e}"); + continue; + } + }; + + let ts = parse_ts_field(&v, "timestamp"); + + if let Some(cutoff) = since { + match ts { + Some(t) if t < cutoff => continue, + _ => {} + } + } + + rollup.parsed_lines += 1; + if let Some(t) = &ts { + rollup.touch_ts(t); + } + + let event = v.get("event").and_then(|e| e.as_str()).unwrap_or(""); + + match event { + "tool.started" | "tool.completed" | "tool.failed" => { + let tool_name = v + .pointer("/payload/tool_name") + .or_else(|| v.pointer("/payload/name")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let stats = rollup.tool_mut(tool_name); + + if event == "tool.started" { + stats.calls += 1; + } else if event == "tool.completed" { + stats.successes += 1; + if let Some(ms) = v.pointer("/payload/elapsed_ms").and_then(|v| v.as_u64()) { + stats.total_elapsed_ms += ms; + stats.elapsed_samples += 1; + } + } else { + // tool.failed + stats.failures += 1; + } + } + "compaction.completed" => { + rollup.compaction.events += 1; + if let Some(ratio) = v + .pointer("/payload/reduction_ratio") + .and_then(|r| r.as_f64()) + { + rollup.compaction.ratio_sum += ratio; + rollup.compaction.ratio_samples += 1; + } + } + "agent.spawned" | "subagent.spawned" => { + rollup.agents.spawns += 1; + } + "agent.completed" | "subagent.completed" => { + let success = v + .pointer("/payload/success") + .and_then(|b| b.as_bool()) + .unwrap_or(true); + if success { + rollup.agents.successes += 1; + } else { + rollup.agents.failures += 1; + } + } + e if e.starts_with("capacity.") => { + rollup.capacity.total += 1; + let category = v + .pointer("/payload/category") + .and_then(|c| c.as_str()) + .unwrap_or(e.trim_start_matches("capacity.")); + *rollup + .capacity + .by_category + .entry(category.to_string()) + .or_insert(0) += 1; + } + _ => {} + } + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Output formatters +// ────────────────────────────────────────────────────────────────────────────── + +fn print_json(rollup: &Rollup) -> Result<()> { + println!("{}", serde_json::to_string_pretty(rollup)?); + Ok(()) +} + +fn print_human(rollup: &Rollup) { + // Period header + match (rollup.earliest_ts, rollup.latest_ts) { + (Some(start), Some(end)) => { + let days = (end - start).num_days(); + println!( + "Period: {} → {} ({} days)", + start.format("%Y-%m-%d"), + end.format("%Y-%m-%d"), + days + ); + } + (Some(start), None) | (None, Some(start)) => { + println!("Period: {} → (unknown)", start.format("%Y-%m-%d")); + } + (None, None) => { + println!("Period: (no data)"); + } + } + + // ── Tools ────────────────────────────────────────────────────────────── + let total_calls = rollup.total_tool_calls(); + if total_calls > 0 { + // Overall success rate from session-file data (where we have result info). + let total_ok: u64 = rollup.tools.values().map(|t| t.successes).sum(); + let total_judged: u64 = rollup + .tools + .values() + .map(|t| t.successes + t.failures) + .sum(); + let overall_rate = if total_judged > 0 { + format!( + "{:.1}% success", + total_ok as f64 / total_judged as f64 * 100.0 + ) + } else { + // Only approval events — show prompt breakdown. + let auto: u64 = rollup.tools.values().map(|t| t.auto_approved).sum(); + let prompted: u64 = rollup.tools.values().map(|t| t.prompted).sum(); + format!("{auto} auto-approved, {prompted} prompted") + }; + + println!( + "Tools: {:>6} calls ({})", + fmt_num(total_calls), + overall_rate + ); + + // Sort tools by call count descending, top 15. + let mut tools: Vec<(&String, &ToolStats)> = rollup.tools.iter().collect(); + tools.sort_by_key(|b| std::cmp::Reverse(b.1.calls)); + for (name, stats) in tools.iter().take(15) { + let rate_str = match stats.success_rate_pct() { + Some(pct) => format!("{pct:5.1}%"), + None => { + // Only approval data available — show auto/prompted breakdown. + let a = stats.auto_approved; + let p = stats.prompted; + if p == 0 { + format!("auto×{a} ") + } else { + format!("auto×{a}/prompted×{p}") + } + } + }; + let avg_str = match stats.avg_elapsed_ms() { + Some(ms) => format!(" avg {ms}ms"), + None => String::new(), + }; + println!( + " {name:<22} {:>6} {rate_str}{avg_str}", + fmt_num(stats.calls) + ); + } + if tools.len() > 15 { + println!(" … and {} more tools", tools.len() - 15); + } + } else { + println!("Tools: (no data)"); + } + + // ── Compaction ───────────────────────────────────────────────────────── + if rollup.compaction.events > 0 { + let avg_str = match rollup.compaction.avg_reduction_pct() { + Some(pct) => format!(", avg {pct:.0}% size reduction"), + None => String::new(), + }; + println!( + "Compaction: {} events{}", + fmt_num(rollup.compaction.events), + avg_str + ); + } else { + println!("Compaction: (no data)"); + } + + // ── Sub-agents ───────────────────────────────────────────────────────── + if rollup.agents.spawns > 0 { + let rate_str = match rollup.agents.success_rate_pct() { + Some(pct) => format!(", {pct:.1}% success"), + None => String::new(), + }; + println!( + "Sub-agents: {} spawns{}", + fmt_num(rollup.agents.spawns), + rate_str + ); + } else { + println!("Sub-agents: (no data)"); + } + + // ── Capacity interventions ───────────────────────────────────────────── + if rollup.capacity.total > 0 { + let cat_str: String = { + let mut cats: Vec<(&String, &u64)> = rollup.capacity.by_category.iter().collect(); + cats.sort_by(|a, b| b.1.cmp(a.1)); + cats.iter() + .map(|(k, v)| format!("{} {}", v, k)) + .collect::>() + .join(", ") + }; + println!( + "Capacity interventions: {} ({})", + fmt_num(rollup.capacity.total), + cat_str + ); + } else { + println!("Capacity interventions: (no data)"); + } + + // ── Credentials ──────────────────────────────────────────────────────── + if rollup.credentials.saves > 0 || rollup.credentials.clears > 0 { + println!( + "Credentials: {} saves, {} clears", + rollup.credentials.saves, rollup.credentials.clears + ); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +fn deepseek_home() -> PathBuf { + // Respect DEEPSEEK_HOME env override; fall back to ~/.deepseek. + if let Ok(v) = std::env::var("DEEPSEEK_HOME") + && !v.is_empty() + { + return PathBuf::from(v); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".deepseek") +} + +/// Parse a timestamp from a JSON value field (tries RFC3339). +fn parse_ts_field(v: &Value, field: &str) -> Option> { + v.get(field)?.as_str()?.parse::>().ok() +} + +/// Format a number with thousands separators. +fn fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut result = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(ch); + } + result.chars().rev().collect() +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Duration parser ── + + #[test] + fn parse_since_7d() { + let cutoff = parse_since("7d").unwrap(); + let expected = Utc::now() - Duration::days(7); + // Allow ±2s for test execution time. + assert!((cutoff - expected).num_seconds().abs() < 2); + } + + #[test] + fn parse_since_24h() { + let cutoff = parse_since("24h").unwrap(); + let expected = Utc::now() - Duration::hours(24); + assert!((cutoff - expected).num_seconds().abs() < 2); + } + + #[test] + fn parse_since_30m() { + let cutoff = parse_since("30m").unwrap(); + let expected = Utc::now() - Duration::minutes(30); + assert!((cutoff - expected).num_seconds().abs() < 2); + } + + #[test] + fn parse_since_now_prefix() { + // "now-2h" should strip "now-" and parse "2h". + let cutoff = parse_since("now-2h").unwrap(); + let expected = Utc::now() - Duration::hours(2); + assert!((cutoff - expected).num_seconds().abs() < 2); + } + + #[test] + fn parse_since_compound() { + let cutoff = parse_since("2h30m").unwrap(); + let expected = Utc::now() - Duration::seconds(2 * 3600 + 30 * 60); + assert!((cutoff - expected).num_seconds().abs() < 2); + } + + #[test] + fn parse_since_compound_days_hours() { + let cutoff = parse_since("1d12h").unwrap(); + let expected = Utc::now() - Duration::seconds(36 * 3600); + assert!((cutoff - expected).num_seconds().abs() < 2); + } + + #[test] + fn parse_since_error_on_invalid() { + assert!(parse_since("xyz").is_err()); + assert!(parse_since("").is_err()); + } + + // ── fmt_num ── + + #[test] + fn fmt_num_zero() { + assert_eq!(fmt_num(0), "0"); + } + + #[test] + fn fmt_num_thousands() { + assert_eq!(fmt_num(1_000), "1,000"); + assert_eq!(fmt_num(12_453), "12,453"); + assert_eq!(fmt_num(1_000_000), "1,000,000"); + } + + // ── Rollup from audit log ── + + fn make_audit_line(event: &str, tool: &str, ts: &str) -> String { + format!( + r#"{{"details":{{"mode":"YOLO","session_id":null,"tool_name":"{tool}"}},"event":"{event}","ts":"{ts}"}}"# + ) + } + + #[test] + fn audit_log_empty_file() { + let mut rollup = Rollup::default(); + // Non-existent path — should not panic, rollup stays empty. + read_audit_log(Path::new("/nonexistent/audit.log"), None, &mut rollup); + assert_eq!(rollup.total_lines, 0); + } + + #[test] + fn audit_log_parses_auto_approve() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + let line1 = make_audit_line( + "tool.approval.auto_approve", + "exec_shell", + "2026-04-01T10:00:00+00:00", + ); + let line2 = make_audit_line( + "tool.approval.auto_approve", + "read_file", + "2026-04-02T10:00:00+00:00", + ); + writeln!(tmp, "{line1}").unwrap(); + writeln!(tmp, "{line2}").unwrap(); + + let mut rollup = Rollup::default(); + read_audit_log(tmp.path(), None, &mut rollup); + + assert_eq!(rollup.parsed_lines, 2); + assert_eq!(rollup.tools["exec_shell"].calls, 1); + assert_eq!(rollup.tools["exec_shell"].auto_approved, 1); + assert_eq!(rollup.tools["read_file"].calls, 1); + } + + #[test] + fn audit_log_skips_malformed_lines() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + writeln!(tmp, "not json at all").unwrap(); + writeln!( + tmp, + r#"{{"event":"credential.save","ts":"2026-04-01T10:00:00+00:00"}}"# + ) + .unwrap(); + + let mut rollup = Rollup::default(); + read_audit_log(tmp.path(), None, &mut rollup); + + // 2 lines total, 1 malformed skipped, 1 parsed. + assert_eq!(rollup.total_lines, 2); + assert_eq!(rollup.parsed_lines, 1); + assert_eq!(rollup.credentials.saves, 1); + } + + #[test] + fn audit_log_since_filter() { + use std::io::Write; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + let line_old = make_audit_line( + "tool.approval.auto_approve", + "exec_shell", + "2025-01-01T00:00:00+00:00", + ); + let line_new = make_audit_line( + "tool.approval.auto_approve", + "read_file", + "2026-04-01T00:00:00+00:00", + ); + writeln!(tmp, "{line_old}").unwrap(); + writeln!(tmp, "{line_new}").unwrap(); + + let cutoff: DateTime = "2026-01-01T00:00:00Z".parse().unwrap(); + let mut rollup = Rollup::default(); + read_audit_log(tmp.path(), Some(cutoff), &mut rollup); + + // Only the newer line should be counted. + assert_eq!(rollup.parsed_lines, 1); + assert!(!rollup.tools.contains_key("exec_shell")); + assert_eq!(rollup.tools["read_file"].calls, 1); + } + + #[test] + fn total_tool_calls_sums_across_tools() { + let mut rollup = Rollup::default(); + rollup.tool_mut("read_file").calls = 4_012; + rollup.tool_mut("exec_shell").calls = 1_118; + assert_eq!(rollup.total_tool_calls(), 5_130); + } +}