Skip to main content

mana/commands/
stats.rs

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// ---------------------------------------------------------------------------
11// Output types (used for both text rendering and JSON serialization)
12// ---------------------------------------------------------------------------
13
14/// Cost and token statistics aggregated from RunRecord history.
15#[derive(Debug, Serialize)]
16pub struct CostStats {
17    pub total_tokens: u64,
18    pub total_cost: f64,
19    pub avg_tokens_per_unit: f64,
20    /// Rate at which closed units passed on their first attempt (0.0–1.0).
21    pub first_pass_rate: f64,
22    /// Rate at which attempted units eventually closed (0.0–1.0).
23    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/// Lightweight unit reference for reporting.
30#[derive(Debug, Serialize)]
31pub struct UnitRef {
32    pub id: String,
33    pub title: String,
34    pub value: u64,
35}
36
37/// Machine-readable snapshot of all stats.
38#[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
50// ---------------------------------------------------------------------------
51// Unit file discovery
52// ---------------------------------------------------------------------------
53
54/// Returns all units loaded from YAML files in `mana_dir` (non-recursive,
55/// skips files that don't look like unit files or fail to parse).
56fn 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
77/// Returns true for files that look like unit YAML files.
78fn is_unit_file(filename: &str) -> bool {
79    filename.ends_with(".yaml") || filename.ends_with(".md")
80}
81
82// ---------------------------------------------------------------------------
83// Aggregation
84// ---------------------------------------------------------------------------
85
86fn 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    // For first-pass rate: closed units where first RunRecord result is Pass
92    let mut closed_with_history: usize = 0;
93    let mut first_pass_count: usize = 0;
94
95    // For overall pass rate: closed / attempted (has any history)
96    let mut attempted: usize = 0;
97    let mut closed_count: usize = 0;
98
99    // For most expensive and most retried
100    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        // Accumulate tokens/cost from all RunRecords
116        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        // First-pass rate: closed units where first RunRecord is a Pass
123        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        // Track most expensive (by total tokens across all attempts)
136        if unit_tokens > 0 && most_expensive.is_none_or(|(_, t)| unit_tokens > t) {
137            most_expensive = Some((unit, unit_tokens));
138        }
139
140        // Track most retried (by number of history entries)
141        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    // Don't show the section at all when nothing has been tracked
148    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
190// ---------------------------------------------------------------------------
191// Command entry point
192// ---------------------------------------------------------------------------
193
194/// Show project statistics: counts by status, priority, and completion percentage.
195/// When `--json` is passed, emits machine-readable JSON instead.
196pub fn cmd_stats(mana_dir: &Path, json: bool) -> Result<()> {
197    let index = Index::load_or_rebuild(mana_dir)?;
198
199    // Count by status
200    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    // Count blocked (open with unresolved dependencies)
218    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    // Count by priority
239    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    // Calculate completion percentage
247    let completion_pct = if total > 0 {
248        (closed as f64 / total as f64) * 100.0
249    } else {
250        0.0
251    };
252
253    // Aggregate cost/token data from full unit files
254    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    // Human-readable output
273    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        // Create units with different statuses and priorities
334        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        // Verify counts
366        assert_eq!(
367            index
368                .units
369                .iter()
370                .filter(|e| e.status == Status::Open)
371                .count(),
372            3
373        ); // 1, 4, 5
374        assert_eq!(
375            index
376                .units
377                .iter()
378                .filter(|e| e.status == Status::InProgress)
379                .count(),
380            1
381        ); // 2
382        assert_eq!(
383            index
384                .units
385                .iter()
386                .filter(|e| e.status == Status::Closed)
387                .count(),
388            1
389        ); // 3
390    }
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}