1use std::fs;
2use std::path::Path;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::index::Index;
8use crate::unit::{RunResult, Status, Unit};
9
10#[derive(Debug, Serialize)]
16pub struct CostStats {
17 pub total_tokens: u64,
18 pub total_cost: f64,
19 pub avg_tokens_per_unit: f64,
20 pub first_pass_rate: f64,
22 pub overall_pass_rate: f64,
24 pub most_expensive_unit: Option<UnitRef>,
25 pub most_retried_unit: Option<UnitRef>,
26 pub units_with_history: usize,
27}
28
29#[derive(Debug, Serialize)]
31pub struct UnitRef {
32 pub id: String,
33 pub title: String,
34 pub value: u64,
35}
36
37#[derive(Debug, Serialize)]
39pub struct StatsOutput {
40 pub total: usize,
41 pub open: usize,
42 pub in_progress: usize,
43 pub closed: usize,
44 pub blocked: usize,
45 pub completion_pct: f64,
46 pub priority_counts: [usize; 5],
47 pub cost: Option<CostStats>,
48}
49
50fn load_all_units(mana_dir: &Path) -> Vec<Unit> {
57 let Ok(entries) = fs::read_dir(mana_dir) else {
58 return vec![];
59 };
60 let mut units = Vec::new();
61 for entry in entries.flatten() {
62 let path = entry.path();
63 let filename = path
64 .file_name()
65 .and_then(|n| n.to_str())
66 .unwrap_or_default();
67 if !is_unit_file(filename) {
68 continue;
69 }
70 if let Ok(unit) = Unit::from_file(&path) {
71 units.push(unit);
72 }
73 }
74 units
75}
76
77fn is_unit_file(filename: &str) -> bool {
79 filename.ends_with(".yaml") || filename.ends_with(".md")
80}
81
82fn aggregate_cost(units: &[Unit]) -> Option<CostStats> {
87 let mut total_tokens: u64 = 0;
88 let mut total_cost: f64 = 0.0;
89 let mut units_with_history: usize = 0;
90
91 let mut closed_with_history: usize = 0;
93 let mut first_pass_count: usize = 0;
94
95 let mut attempted: usize = 0;
97 let mut closed_count: usize = 0;
98
99 let mut most_expensive: Option<(&Unit, u64)> = None;
101 let mut most_retried: Option<(&Unit, usize)> = None;
102
103 for unit in units {
104 if unit.history.is_empty() {
105 continue;
106 }
107
108 units_with_history += 1;
109 attempted += 1;
110
111 if unit.status == Status::Closed {
112 closed_count += 1;
113 }
114
115 let unit_tokens: u64 = unit.history.iter().filter_map(|r| r.tokens).sum();
117 let unit_cost: f64 = unit.history.iter().filter_map(|r| r.cost).sum();
118
119 total_tokens += unit_tokens;
120 total_cost += unit_cost;
121
122 if unit.status == Status::Closed {
124 closed_with_history += 1;
125 if unit
126 .history
127 .first()
128 .map(|r| r.result == RunResult::Pass)
129 .unwrap_or(false)
130 {
131 first_pass_count += 1;
132 }
133 }
134
135 if unit_tokens > 0 && most_expensive.is_none_or(|(_, t)| unit_tokens > t) {
137 most_expensive = Some((unit, unit_tokens));
138 }
139
140 let attempt_count = unit.history.len();
142 if attempt_count > 1 && most_retried.is_none_or(|(_, c)| attempt_count > c) {
143 most_retried = Some((unit, attempt_count));
144 }
145 }
146
147 if units_with_history == 0 {
149 return None;
150 }
151
152 let avg_tokens_per_unit = if units_with_history > 0 {
153 total_tokens as f64 / units_with_history as f64
154 } else {
155 0.0
156 };
157
158 let first_pass_rate = if closed_with_history > 0 {
159 first_pass_count as f64 / closed_with_history as f64
160 } else {
161 0.0
162 };
163
164 let overall_pass_rate = if attempted > 0 {
165 closed_count as f64 / attempted as f64
166 } else {
167 0.0
168 };
169
170 Some(CostStats {
171 total_tokens,
172 total_cost,
173 avg_tokens_per_unit,
174 first_pass_rate,
175 overall_pass_rate,
176 most_expensive_unit: most_expensive.map(|(b, tokens)| UnitRef {
177 id: b.id.clone(),
178 title: b.title.clone(),
179 value: tokens,
180 }),
181 most_retried_unit: most_retried.map(|(b, count)| UnitRef {
182 id: b.id.clone(),
183 title: b.title.clone(),
184 value: count as u64,
185 }),
186 units_with_history,
187 })
188}
189
190pub fn cmd_stats(mana_dir: &Path, json: bool) -> Result<()> {
197 let index = Index::load_or_rebuild(mana_dir)?;
198
199 let total = index.units.len();
201 let open = index
202 .units
203 .iter()
204 .filter(|e| e.status == Status::Open)
205 .count();
206 let in_progress = index
207 .units
208 .iter()
209 .filter(|e| e.status == Status::InProgress)
210 .count();
211 let closed = index
212 .units
213 .iter()
214 .filter(|e| e.status == Status::Closed)
215 .count();
216
217 let blocked = index
219 .units
220 .iter()
221 .filter(|e| {
222 if e.status != Status::Open {
223 return false;
224 }
225 for dep_id in &e.dependencies {
226 if let Some(dep) = index.units.iter().find(|d| &d.id == dep_id) {
227 if dep.status != Status::Closed {
228 return true;
229 }
230 } else {
231 return true;
232 }
233 }
234 false
235 })
236 .count();
237
238 let mut priority_counts = [0usize; 5];
240 for entry in &index.units {
241 if (entry.priority as usize) < 5 {
242 priority_counts[entry.priority as usize] += 1;
243 }
244 }
245
246 let completion_pct = if total > 0 {
248 (closed as f64 / total as f64) * 100.0
249 } else {
250 0.0
251 };
252
253 let all_units = load_all_units(mana_dir);
255 let cost = aggregate_cost(&all_units);
256
257 if json {
258 let output = StatsOutput {
259 total,
260 open,
261 in_progress,
262 closed,
263 blocked,
264 completion_pct,
265 priority_counts,
266 cost,
267 };
268 println!("{}", serde_json::to_string_pretty(&output)?);
269 return Ok(());
270 }
271
272 println!("=== Unit Statistics ===");
274 println!();
275 println!("Total: {}", total);
276 println!("Open: {}", open);
277 println!("In Progress: {}", in_progress);
278 println!("Closed: {}", closed);
279 println!("Blocked: {}", blocked);
280 println!();
281 println!("Completion: {:.1}%", completion_pct);
282 println!();
283 println!("By Priority:");
284 println!(" P0: {}", priority_counts[0]);
285 println!(" P1: {}", priority_counts[1]);
286 println!(" P2: {}", priority_counts[2]);
287 println!(" P3: {}", priority_counts[3]);
288 println!(" P4: {}", priority_counts[4]);
289
290 if let Some(c) = &cost {
291 println!();
292 println!("=== Tokens & Cost ===");
293 println!();
294 println!("Units tracked: {}", c.units_with_history);
295 println!("Total tokens: {}", c.total_tokens);
296 if c.total_cost > 0.0 {
297 println!("Total cost: ${:.4}", c.total_cost);
298 }
299 println!("Avg tokens/unit: {:.0}", c.avg_tokens_per_unit);
300 println!();
301 println!("First-pass rate: {:.1}%", c.first_pass_rate * 100.0);
302 println!("Overall pass rate:{:.1}%", c.overall_pass_rate * 100.0);
303 if let Some(ref unit) = c.most_expensive_unit {
304 println!();
305 println!(
306 "Most expensive: {} — {} ({} tokens)",
307 unit.id, unit.title, unit.value
308 );
309 }
310 if let Some(ref unit) = c.most_retried_unit {
311 println!(
312 "Most retried: {} — {} ({} attempts)",
313 unit.id, unit.title, unit.value
314 );
315 }
316 }
317
318 Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::unit::Unit;
325 use std::fs;
326 use tempfile::TempDir;
327
328 fn setup_test_units() -> (TempDir, std::path::PathBuf) {
329 let dir = TempDir::new().unwrap();
330 let mana_dir = dir.path().join(".mana");
331 fs::create_dir(&mana_dir).unwrap();
332
333 let mut b1 = Unit::new("1", "Open P0");
335 b1.priority = 0;
336
337 let mut b2 = Unit::new("2", "In Progress P1");
338 b2.status = Status::InProgress;
339 b2.priority = 1;
340
341 let mut b3 = Unit::new("3", "Closed P2");
342 b3.status = Status::Closed;
343 b3.priority = 2;
344
345 let mut b4 = Unit::new("4", "Open P3");
346 b4.priority = 3;
347
348 let mut b5 = Unit::new("5", "Open depends on 1");
349 b5.dependencies = vec!["1".to_string()];
350
351 b1.to_file(mana_dir.join("1.yaml")).unwrap();
352 b2.to_file(mana_dir.join("2.yaml")).unwrap();
353 b3.to_file(mana_dir.join("3.yaml")).unwrap();
354 b4.to_file(mana_dir.join("4.yaml")).unwrap();
355 b5.to_file(mana_dir.join("5.yaml")).unwrap();
356
357 (dir, mana_dir)
358 }
359
360 #[test]
361 fn stats_calculates_counts() {
362 let (_dir, mana_dir) = setup_test_units();
363 let index = Index::load_or_rebuild(&mana_dir).unwrap();
364
365 assert_eq!(
367 index
368 .units
369 .iter()
370 .filter(|e| e.status == Status::Open)
371 .count(),
372 3
373 ); assert_eq!(
375 index
376 .units
377 .iter()
378 .filter(|e| e.status == Status::InProgress)
379 .count(),
380 1
381 ); assert_eq!(
383 index
384 .units
385 .iter()
386 .filter(|e| e.status == Status::Closed)
387 .count(),
388 1
389 ); }
391
392 #[test]
393 fn stats_command_works() {
394 let (_dir, mana_dir) = setup_test_units();
395 let result = cmd_stats(&mana_dir, false);
396 assert!(result.is_ok());
397 }
398
399 #[test]
400 fn stats_command_json() {
401 let (_dir, mana_dir) = setup_test_units();
402 let result = cmd_stats(&mana_dir, true);
403 assert!(result.is_ok());
404 }
405
406 #[test]
407 fn empty_project() {
408 let dir = TempDir::new().unwrap();
409 let mana_dir = dir.path().join(".mana");
410 fs::create_dir(&mana_dir).unwrap();
411
412 let result = cmd_stats(&mana_dir, false);
413 assert!(result.is_ok());
414 }
415
416 #[test]
417 fn aggregate_cost_no_history() {
418 let units = vec![Unit::new("1", "No history")];
419 let result = aggregate_cost(&units);
420 assert!(
421 result.is_none(),
422 "Should return None when no units have history"
423 );
424 }
425
426 #[test]
427 fn aggregate_cost_with_history() {
428 use crate::unit::{RunRecord, RunResult};
429 use chrono::Utc;
430
431 let mut unit = Unit::new("1", "With history");
432 unit.status = Status::Closed;
433 unit.history = vec![RunRecord {
434 attempt: 1,
435 started_at: Utc::now(),
436 finished_at: None,
437 duration_secs: None,
438 agent: None,
439 result: RunResult::Pass,
440 exit_code: Some(0),
441 tokens: Some(1000),
442 cost: Some(0.05),
443 output_snippet: None,
444 }];
445
446 let stats = aggregate_cost(&[unit]).unwrap();
447 assert_eq!(stats.total_tokens, 1000);
448 assert!((stats.total_cost - 0.05).abs() < 1e-9);
449 assert_eq!(stats.units_with_history, 1);
450 assert!((stats.first_pass_rate - 1.0).abs() < 1e-9);
451 assert!((stats.overall_pass_rate - 1.0).abs() < 1e-9);
452 }
453
454 #[test]
455 fn aggregate_cost_most_expensive_and_retried() {
456 use crate::unit::{RunRecord, RunResult};
457 use chrono::Utc;
458
459 let make_record = |tokens: u64, result: RunResult| RunRecord {
460 attempt: 1,
461 started_at: Utc::now(),
462 finished_at: None,
463 duration_secs: None,
464 agent: None,
465 result,
466 exit_code: None,
467 tokens: Some(tokens),
468 cost: None,
469 output_snippet: None,
470 };
471
472 let mut cheap = Unit::new("1", "Cheap unit");
473 cheap.history = vec![make_record(100, RunResult::Fail)];
474
475 let mut expensive = Unit::new("2", "Expensive unit");
476 expensive.history = vec![
477 make_record(5000, RunResult::Fail),
478 make_record(3000, RunResult::Pass),
479 ];
480 expensive.status = Status::Closed;
481
482 let stats = aggregate_cost(&[cheap, expensive]).unwrap();
483 assert_eq!(stats.total_tokens, 8100);
484 let exp = stats.most_expensive_unit.unwrap();
485 assert_eq!(exp.id, "2");
486 assert_eq!(exp.value, 8000);
487
488 let retried = stats.most_retried_unit.unwrap();
489 assert_eq!(retried.id, "2");
490 assert_eq!(retried.value, 2);
491 }
492}