Skip to main content

orch/cli/
stats.rs

1use std::fs;
2use std::path::PathBuf;
3
4use crate::cli;
5use crate::config;
6use crate::store::CostSummary;
7use crate::store::MetricsSummary;
8
9/// Show task metrics and statistics, per-project by default.
10///
11/// `since_hours` controls the time window (default 24; pass 168 for 7 days, etc.).
12pub async fn stats(all: bool, since_hours: u32) -> anyhow::Result<()> {
13    let store = cli::init_store().await?;
14    let window_label = cli::hours_to_label(since_hours);
15
16    if all {
17        let summary = store.get_metrics_summary(since_hours).await?;
18        let cost = store.get_cost_summary().await.ok();
19        println!();
20        print_summary_table("All Projects", &window_label, &summary, cost.as_ref());
21    } else {
22        let repos = get_configured_repos();
23        if repos.is_empty() {
24            // Fallback to global stats if no projects configured
25            let summary = store.get_metrics_summary(since_hours).await?;
26            let cost = store.get_cost_summary().await.ok();
27            println!();
28            print_summary_table("All Projects", &window_label, &summary, cost.as_ref());
29        } else {
30            println!();
31            for repo in &repos {
32                let summary = store.get_metrics_summary_by_repo(repo, since_hours).await?;
33                let cost = store.get_cost_summary_by_repo(repo, since_hours).await.ok();
34                print_summary_table(repo, &window_label, &summary, cost.as_ref());
35            }
36        }
37    }
38
39    Ok(())
40}
41
42fn print_summary_table(
43    title: &str,
44    window_label: &str,
45    summary: &MetricsSummary,
46    cost: Option<&CostSummary>,
47) {
48    println!("── {title} ──────────────────────────────────");
49
50    let total = summary.tasks_completed_24h + summary.tasks_failed_24h;
51    #[expect(
52        clippy::cast_precision_loss,
53        reason = "a display percentage of task counts far below 2^52"
54    )]
55    let success_rate = if total > 0 {
56        summary.tasks_completed_24h as f64 / total as f64 * 100.0
57    } else {
58        0.0
59    };
60
61    println!(
62        "  Tasks ({}):  {} completed, {} failed",
63        window_label, summary.tasks_completed_24h, summary.tasks_failed_24h
64    );
65    println!("  Success rate: {success_rate:.1}%");
66
67    // Duration by complexity
68    let mut durations = Vec::new();
69    if let Some(d) = summary.avg_duration_simple {
70        durations.push(format!("simple {:.1}m", d / 60.0));
71    }
72    if let Some(d) = summary.avg_duration_medium {
73        durations.push(format!("medium {:.1}m", d / 60.0));
74    }
75    if let Some(d) = summary.avg_duration_complex {
76        durations.push(format!("complex {:.1}m", d / 60.0));
77    }
78    if !durations.is_empty() {
79        println!("  Avg duration: {}", durations.join(" | "));
80    }
81
82    // Agent stats
83    if !summary.agent_stats.is_empty() {
84        let agents: Vec<String> = summary
85            .agent_stats
86            .iter()
87            .map(|a| format!("{}: {} ({:.0}%)", a.agent, a.total_runs, a.success_rate))
88            .collect();
89        println!("  Agents:       {}", agents.join(" | "));
90    }
91
92    // Cost: show total across all recorded periods
93    if let Some(c) = cost {
94        let total_cost: f64 = c.periods.iter().map(|p| p.total_cost_usd).sum();
95        if total_cost > 0.0 {
96            if let Some(period) = c.periods.iter().find(|p| p.label == window_label) {
97                println!("  Cost ({}): ${:.2}", window_label, period.total_cost_usd);
98            } else if let Some(period_24h) = c.periods.iter().find(|p| p.label == "24h")
99                && period_24h.total_cost_usd > 0.0
100            {
101                println!("  Cost (24h):   ${:.2}", period_24h.total_cost_usd);
102            }
103        }
104    }
105
106    if summary.rate_limits_24h > 0 {
107        println!("  Rate limits:  {}", summary.rate_limits_24h);
108    }
109
110    println!();
111}
112
113/// Get all configured project repos from global config + .orch.yml files.
114fn get_configured_repos() -> Vec<String> {
115    let mut repos = Vec::new();
116
117    // Try current project
118    if let Ok(repo) = config::get_current_repo()
119        && !repo.is_empty()
120    {
121        repos.push(repo);
122    }
123
124    // Try projects from global config
125    if let Ok(paths) = config::get_project_paths() {
126        for path_str in &paths {
127            let candidate = PathBuf::from(path_str).join(".orch.yml");
128            if candidate.exists()
129                && let Ok(content) = fs::read_to_string(&candidate)
130                && let Ok(val) = serde_norway::from_str::<serde_norway::Value>(&content)
131                && let Some(repo) = val
132                    .get("gh")
133                    .and_then(|g| g.get("repo"))
134                    .and_then(|r| r.as_str())
135                && !repos.contains(&repo.to_string())
136            {
137                repos.push(repo.to_string());
138            }
139        }
140    }
141
142    repos
143}