1use crate::core::config;
5use crate::core::data_source::DataSource;
6use crate::metrics::{index, report};
7use crate::shell::cli::{maybe_refresh_store, open_workspace_read_store, workspace_path};
8use crate::shell::remote_pull::maybe_telemetry_pull;
9use crate::shell::scope;
10use crate::store::Store;
11use crate::sync::{ingest_ctx, smart};
12use anyhow::Result;
13use std::path::Path;
14
15pub fn metrics_text(
17 workspace: Option<&Path>,
18 days: u32,
19 json_out: bool,
20 force: bool,
21 all_workspaces: bool,
22 refresh: bool,
23 source: DataSource,
24) -> Result<String> {
25 let roots = scope::resolve(workspace, all_workspaces)?;
26 let mut reports = Vec::new();
27 for workspace in &roots {
28 let cfg = config::load(workspace)?;
29 let store = open_workspace_read_store(workspace, refresh || source != DataSource::Local)?;
30 maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
31 maybe_refresh_store(workspace, &store, refresh)?;
32 if force {
33 let snapshot = index::ensure_indexed(&store, workspace, true)?;
34 maybe_enqueue_snapshot(&store, &cfg, workspace, &snapshot)?;
35 }
36 let ws_str = workspace.to_string_lossy().to_string();
37 if let Ok(mut report) = report::build_report(&store, &ws_str, days) {
38 if source != DataSource::Local
39 && let Ok(Some(agg)) =
40 crate::shell::remote_observe::try_remote_event_agg(&store, &cfg, workspace)
41 {
42 report =
43 crate::shell::remote_observe::apply_remote_to_metrics(report, &agg, source);
44 }
45 reports.push(if roots.len() == 1 {
46 report
47 } else {
48 decorate_metrics(workspace, report)
49 });
50 }
51 }
52 let metrics = merge_metrics(reports);
53 if json_out {
54 return Ok(serde_json::to_string_pretty(&metrics)?);
55 }
56 Ok(format_human(&metrics))
57}
58
59pub fn cmd_metrics(
60 workspace: Option<&Path>,
61 days: u32,
62 json_out: bool,
63 force: bool,
64 all_workspaces: bool,
65 refresh: bool,
66 source: DataSource,
67) -> Result<()> {
68 print!(
69 "{}",
70 metrics_text(
71 workspace,
72 days,
73 json_out,
74 force,
75 all_workspaces,
76 refresh,
77 source
78 )?
79 );
80 Ok(())
81}
82
83pub fn metrics_index_text(workspace: Option<&Path>, force: bool) -> Result<String> {
85 let ws = workspace_path(workspace)?;
86 let cfg = config::load(&ws)?;
87 let db_path = ws.join(".kaizen/kaizen.db");
88 let store = Store::open(&db_path)?;
89 let snapshot = index::ensure_indexed(&store, &ws, force)?;
90 maybe_enqueue_snapshot(&store, &cfg, &ws, &snapshot)?;
91 use std::fmt::Write;
92 let mut s = String::new();
93 writeln!(&mut s, "snapshot: {}", snapshot.id).unwrap();
94 writeln!(&mut s, "graph: {}", snapshot.graph_path).unwrap();
95 Ok(s)
96}
97
98pub fn cmd_metrics_index(workspace: Option<&Path>, force: bool) -> Result<()> {
99 print!("{}", metrics_index_text(workspace, force)?);
100 Ok(())
101}
102
103fn decorate_metrics(
104 workspace: &Path,
105 mut metrics: crate::metrics::types::MetricsReport,
106) -> crate::metrics::types::MetricsReport {
107 for row in &mut metrics.hottest_files {
108 row.path = scope::decorate_path(workspace, &row.path);
109 }
110 for row in &mut metrics.most_changed_files {
111 row.path = scope::decorate_path(workspace, &row.path);
112 }
113 for row in &mut metrics.most_complex_files {
114 row.path = scope::decorate_path(workspace, &row.path);
115 }
116 for row in &mut metrics.highest_risk_files {
117 row.path = scope::decorate_path(workspace, &row.path);
118 }
119 for row in &mut metrics.agent_pain_hotspots {
120 row.path = scope::decorate_path(workspace, &row.path);
121 }
122 metrics
123}
124
125fn merge_metrics(
126 rows: Vec<crate::metrics::types::MetricsReport>,
127) -> crate::metrics::types::MetricsReport {
128 let mut out = crate::metrics::types::MetricsReport {
129 snapshot: None,
130 hottest_files: Vec::new(),
131 most_changed_files: Vec::new(),
132 most_complex_files: Vec::new(),
133 highest_risk_files: Vec::new(),
134 slowest_tools: Vec::new(),
135 highest_token_tools: Vec::new(),
136 highest_reasoning_tools: Vec::new(),
137 agent_pain_hotspots: Vec::new(),
138 };
139 for row in rows {
140 out.hottest_files.extend(row.hottest_files);
141 out.most_changed_files.extend(row.most_changed_files);
142 out.most_complex_files.extend(row.most_complex_files);
143 out.highest_risk_files.extend(row.highest_risk_files);
144 out.agent_pain_hotspots.extend(row.agent_pain_hotspots);
145 merge_tool_rows(&mut out.slowest_tools, row.slowest_tools);
146 merge_tool_rows(&mut out.highest_token_tools, row.highest_token_tools);
147 merge_tool_rows(
148 &mut out.highest_reasoning_tools,
149 row.highest_reasoning_tools,
150 );
151 }
152 trim_file_rows(&mut out.hottest_files);
153 trim_file_rows(&mut out.most_changed_files);
154 trim_file_rows(&mut out.most_complex_files);
155 trim_file_rows(&mut out.highest_risk_files);
156 trim_file_rows(&mut out.agent_pain_hotspots);
157 trim_tool_rows(&mut out.slowest_tools, |row| row.p95_ms.unwrap_or(0));
158 trim_tool_rows(&mut out.highest_token_tools, |row| row.total_tokens);
159 trim_tool_rows(&mut out.highest_reasoning_tools, |row| {
160 row.total_reasoning_tokens
161 });
162 out
163}
164
165fn merge_tool_rows(
166 target: &mut Vec<crate::metrics::types::RankedTool>,
167 rows: Vec<crate::metrics::types::RankedTool>,
168) {
169 for row in rows {
170 if let Some(existing) = target.iter_mut().find(|item| item.tool == row.tool) {
171 existing.calls += row.calls;
172 existing.total_tokens += row.total_tokens;
173 existing.total_reasoning_tokens += row.total_reasoning_tokens;
174 existing.p50_ms = existing.p50_ms.max(row.p50_ms);
175 existing.p95_ms = existing.p95_ms.max(row.p95_ms);
176 continue;
177 }
178 target.push(row);
179 }
180}
181
182fn trim_file_rows(rows: &mut Vec<crate::metrics::types::RankedFile>) {
183 rows.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.path.cmp(&b.path)));
184 rows.truncate(10);
185}
186
187fn trim_tool_rows<F>(rows: &mut Vec<crate::metrics::types::RankedTool>, rank: F)
188where
189 F: Fn(&crate::metrics::types::RankedTool) -> u64,
190{
191 rows.sort_by(|a, b| rank(b).cmp(&rank(a)).then_with(|| a.tool.cmp(&b.tool)));
192 rows.truncate(10);
193}
194
195fn maybe_enqueue_snapshot(
196 store: &Store,
197 cfg: &crate::core::config::Config,
198 ws: &std::path::Path,
199 snapshot: &crate::metrics::types::RepoSnapshotRecord,
200) -> Result<()> {
201 let Some(ctx) = ingest_ctx(cfg, ws.to_path_buf()) else {
202 return Ok(());
203 };
204 let facts = store.file_facts_for_snapshot(&snapshot.id)?;
205 let edges = store.repo_edges_for_snapshot(&snapshot.id)?;
206 smart::enqueue_repo_snapshot(store, snapshot, &facts, &edges, &ctx)?;
207 smart::enqueue_workspace_fact_snapshot(store, ws, &ctx)
208}
209
210pub fn print_human(metrics: &crate::metrics::types::MetricsReport) {
211 print!("{}", format_human(metrics));
212}
213
214fn format_files(title: &str, rows: &[crate::metrics::types::RankedFile]) -> String {
215 use std::fmt::Write;
216 let mut s = String::new();
217 writeln!(&mut s, "{title}").unwrap();
218 if rows.is_empty() {
219 writeln!(&mut s, " (none)").unwrap();
220 writeln!(&mut s).unwrap();
221 return s;
222 }
223 for row in rows.iter().take(5) {
224 writeln!(&mut s, " {:>8} {}", row.value, row.path).unwrap();
225 }
226 writeln!(&mut s).unwrap();
227 s
228}
229
230fn format_tools(title: &str, rows: &[crate::metrics::types::RankedTool]) -> String {
231 use std::fmt::Write;
232 let mut s = String::new();
233 writeln!(&mut s, "{title}").unwrap();
234 if rows.is_empty() {
235 writeln!(&mut s, " (none)").unwrap();
236 writeln!(&mut s).unwrap();
237 return s;
238 }
239 for row in rows.iter().take(5) {
240 let p95 = row
241 .p95_ms
242 .map(|v| format!("{v}ms"))
243 .unwrap_or_else(|| "-".into());
244 writeln!(
245 &mut s,
246 " {:<14} calls={} p95={} tok={} rtok={}",
247 row.tool, row.calls, p95, row.total_tokens, row.total_reasoning_tokens
248 )
249 .unwrap();
250 }
251 writeln!(&mut s).unwrap();
252 s
253}
254
255fn format_human(metrics: &crate::metrics::types::MetricsReport) -> String {
256 let mut out = String::new();
257 out.push_str(&format_files("Hottest files", &metrics.hottest_files));
258 out.push_str(&format_files("Most changed", &metrics.most_changed_files));
259 out.push_str(&format_files("Most complex", &metrics.most_complex_files));
260 out.push_str(&format_files("Highest risk", &metrics.highest_risk_files));
261 out.push_str(&format_tools("Slowest tools", &metrics.slowest_tools));
262 out.push_str(&format_tools(
263 "Highest token tools",
264 &metrics.highest_token_tools,
265 ));
266 out.push_str(&format_tools(
267 "Highest reasoning tools",
268 &metrics.highest_reasoning_tools,
269 ));
270 out.push_str(&format_files(
271 "Agent pain hotspots",
272 &metrics.agent_pain_hotspots,
273 ));
274 use std::fmt::Write;
275 let _ = writeln!(&mut out);
276 let _ = writeln!(&mut out, "Takeaway");
277 if let Some(f) = metrics.hottest_files.first() {
278 let _ = writeln!(
279 &mut out,
280 " · Review {} first (heat {}); pair with `kaizen insights` for session context",
281 f.path, f.value
282 );
283 }
284 if let Some(t) = metrics.slowest_tools.first() {
285 let p95 = t
286 .p95_ms
287 .map(|v| format!("{v}ms"))
288 .unwrap_or_else(|| "n/a".into());
289 let _ = writeln!(
290 &mut out,
291 " · Latency focus: {} p95 {} — tune or cache this tool path if it recurs",
292 t.tool, p95
293 );
294 }
295 if let Some(t) = metrics.highest_token_tools.first() {
296 let _ = writeln!(
297 &mut out,
298 " · Token sink: {} ({} total tok) — compare week over week with `kaizen metrics --days 30`",
299 t.tool, t.total_tokens
300 );
301 }
302 let _ = writeln!(
303 &mut out,
304 " · Next: `kaizen retro --days 7` for team-level bets"
305 );
306 out
307}