Skip to main content

orch/cli/
stats.rs

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