1use std::fs;
2use std::path::PathBuf;
3
4use crate::cli;
5use crate::config;
6use crate::store::CostSummary;
7use crate::store::MetricsSummary;
8
9pub 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 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 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 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 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
113fn get_configured_repos() -> Vec<String> {
115 let mut repos = Vec::new();
116
117 if let Ok(repo) = config::get_current_repo()
119 && !repo.is_empty()
120 {
121 repos.push(repo);
122 }
123
124 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}