Skip to main content

mana_core/ops/
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/// Cost and token statistics aggregated from RunRecord history.
11#[derive(Debug, Serialize)]
12pub struct CostStats {
13    pub total_tokens: u64,
14    pub total_cost: f64,
15    pub avg_tokens_per_unit: f64,
16    /// Rate at which closed units passed on their first attempt (0.0–1.0).
17    pub first_pass_rate: f64,
18    /// Rate at which attempted units eventually closed (0.0–1.0).
19    pub overall_pass_rate: f64,
20    pub most_expensive_unit: Option<UnitRef>,
21    pub most_retried_unit: Option<UnitRef>,
22    pub units_with_history: usize,
23}
24
25/// Lightweight unit reference for reporting.
26#[derive(Debug, Serialize)]
27pub struct UnitRef {
28    pub id: String,
29    pub title: String,
30    pub value: u64,
31}
32
33/// Project statistics snapshot.
34#[derive(Debug, Serialize)]
35pub struct StatsResult {
36    pub total: usize,
37    pub open: usize,
38    pub in_progress: usize,
39    pub closed: usize,
40    pub blocked: usize,
41    pub completion_pct: f64,
42    pub priority_counts: [usize; 5],
43    pub cost: Option<CostStats>,
44}
45
46/// Load all units from disk (non-recursive, skips non-unit files).
47fn load_all_units(mana_dir: &Path) -> Vec<Unit> {
48    let Ok(entries) = fs::read_dir(mana_dir) else {
49        return vec![];
50    };
51    let mut units = Vec::new();
52    for entry in entries.flatten() {
53        let path = entry.path();
54        let filename = path
55            .file_name()
56            .and_then(|n| n.to_str())
57            .unwrap_or_default();
58        if !(filename.ends_with(".yaml") || filename.ends_with(".md")) {
59            continue;
60        }
61        if let Ok(unit) = Unit::from_file(&path) {
62            units.push(unit);
63        }
64    }
65    units
66}
67
68/// Aggregate cost/token statistics from unit history.
69pub fn aggregate_cost(units: &[Unit]) -> Option<CostStats> {
70    let mut total_tokens: u64 = 0;
71    let mut total_cost: f64 = 0.0;
72    let mut units_with_history: usize = 0;
73    let mut closed_with_history: usize = 0;
74    let mut first_pass_count: usize = 0;
75    let mut attempted: usize = 0;
76    let mut closed_count: usize = 0;
77    let mut most_expensive: Option<(&Unit, u64)> = None;
78    let mut most_retried: Option<(&Unit, usize)> = None;
79
80    for unit in units {
81        if unit.history.is_empty() {
82            continue;
83        }
84
85        units_with_history += 1;
86        attempted += 1;
87
88        if unit.status == Status::Closed {
89            closed_count += 1;
90        }
91
92        let unit_tokens: u64 = unit.history.iter().filter_map(|r| r.tokens).sum();
93        let unit_cost: f64 = unit.history.iter().filter_map(|r| r.cost).sum();
94
95        total_tokens += unit_tokens;
96        total_cost += unit_cost;
97
98        if unit.status == Status::Closed {
99            closed_with_history += 1;
100            if unit
101                .history
102                .first()
103                .map(|r| r.result == RunResult::Pass)
104                .unwrap_or(false)
105            {
106                first_pass_count += 1;
107            }
108        }
109
110        if unit_tokens > 0 && most_expensive.is_none_or(|(_, t)| unit_tokens > t) {
111            most_expensive = Some((unit, unit_tokens));
112        }
113
114        let attempt_count = unit.history.len();
115        if attempt_count > 1 && most_retried.is_none_or(|(_, c)| attempt_count > c) {
116            most_retried = Some((unit, attempt_count));
117        }
118    }
119
120    if units_with_history == 0 {
121        return None;
122    }
123
124    let avg_tokens_per_unit = if units_with_history > 0 {
125        total_tokens as f64 / units_with_history as f64
126    } else {
127        0.0
128    };
129
130    let first_pass_rate = if closed_with_history > 0 {
131        first_pass_count as f64 / closed_with_history as f64
132    } else {
133        0.0
134    };
135
136    let overall_pass_rate = if attempted > 0 {
137        closed_count as f64 / attempted as f64
138    } else {
139        0.0
140    };
141
142    Some(CostStats {
143        total_tokens,
144        total_cost,
145        avg_tokens_per_unit,
146        first_pass_rate,
147        overall_pass_rate,
148        most_expensive_unit: most_expensive.map(|(b, tokens)| UnitRef {
149            id: b.id.clone(),
150            title: b.title.clone(),
151            value: tokens,
152        }),
153        most_retried_unit: most_retried.map(|(b, count)| UnitRef {
154            id: b.id.clone(),
155            title: b.title.clone(),
156            value: count as u64,
157        }),
158        units_with_history,
159    })
160}
161
162/// Compute project statistics: counts by status, priority, cost metrics.
163pub fn stats(mana_dir: &Path) -> Result<StatsResult> {
164    let index = Index::load_or_rebuild(mana_dir)?;
165
166    let total = index.units.len();
167    let open = index
168        .units
169        .iter()
170        .filter(|e| e.status == Status::Open)
171        .count();
172    let in_progress = index
173        .units
174        .iter()
175        .filter(|e| e.status == Status::InProgress)
176        .count();
177    let closed = index
178        .units
179        .iter()
180        .filter(|e| e.status == Status::Closed)
181        .count();
182
183    let blocked = index
184        .units
185        .iter()
186        .filter(|e| {
187            if e.status != Status::Open {
188                return false;
189            }
190            for dep_id in &e.dependencies {
191                if let Some(dep) = index.units.iter().find(|d| &d.id == dep_id) {
192                    if dep.status != Status::Closed {
193                        return true;
194                    }
195                } else {
196                    return true;
197                }
198            }
199            false
200        })
201        .count();
202
203    let mut priority_counts = [0usize; 5];
204    for entry in &index.units {
205        if (entry.priority as usize) < 5 {
206            priority_counts[entry.priority as usize] += 1;
207        }
208    }
209
210    let completion_pct = if total > 0 {
211        (closed as f64 / total as f64) * 100.0
212    } else {
213        0.0
214    };
215
216    let all_units = load_all_units(mana_dir);
217    let cost = aggregate_cost(&all_units);
218
219    Ok(StatsResult {
220        total,
221        open,
222        in_progress,
223        closed,
224        blocked,
225        completion_pct,
226        priority_counts,
227        cost,
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::unit::{RunRecord, RunResult, Unit};
235    use chrono::Utc;
236    use std::fs;
237    use tempfile::TempDir;
238
239    fn setup_test_units() -> (TempDir, std::path::PathBuf) {
240        let dir = TempDir::new().unwrap();
241        let mana_dir = dir.path().join(".mana");
242        fs::create_dir(&mana_dir).unwrap();
243
244        let mut b1 = Unit::new("1", "Open P0");
245        b1.priority = 0;
246        let mut b2 = Unit::new("2", "In Progress P1");
247        b2.status = Status::InProgress;
248        b2.priority = 1;
249        let mut b3 = Unit::new("3", "Closed P2");
250        b3.status = Status::Closed;
251        b3.priority = 2;
252
253        b1.to_file(mana_dir.join("1.yaml")).unwrap();
254        b2.to_file(mana_dir.join("2.yaml")).unwrap();
255        b3.to_file(mana_dir.join("3.yaml")).unwrap();
256
257        (dir, mana_dir)
258    }
259
260    #[test]
261    fn stats_computes_counts() {
262        let (_dir, mana_dir) = setup_test_units();
263
264        let result = stats(&mana_dir).unwrap();
265
266        assert_eq!(result.total, 3);
267        assert_eq!(result.open, 1);
268        assert_eq!(result.in_progress, 1);
269        assert_eq!(result.closed, 1);
270    }
271
272    #[test]
273    fn stats_empty_project() {
274        let dir = TempDir::new().unwrap();
275        let mana_dir = dir.path().join(".mana");
276        fs::create_dir(&mana_dir).unwrap();
277
278        let result = stats(&mana_dir).unwrap();
279
280        assert_eq!(result.total, 0);
281        assert_eq!(result.completion_pct, 0.0);
282    }
283
284    #[test]
285    fn aggregate_cost_no_history() {
286        let units = vec![Unit::new("1", "No history")];
287        let result = aggregate_cost(&units);
288        assert!(result.is_none());
289    }
290
291    #[test]
292    fn aggregate_cost_with_history() {
293        let mut unit = Unit::new("1", "With history");
294        unit.status = Status::Closed;
295        unit.history = vec![RunRecord {
296            attempt: 1,
297            started_at: Utc::now(),
298            finished_at: None,
299            duration_secs: None,
300            agent: None,
301            result: RunResult::Pass,
302            exit_code: Some(0),
303            tokens: Some(1000),
304            cost: Some(0.05),
305            output_snippet: None,
306        }];
307
308        let stats = aggregate_cost(&[unit]).unwrap();
309        assert_eq!(stats.total_tokens, 1000);
310        assert!((stats.total_cost - 0.05).abs() < 1e-9);
311        assert!((stats.first_pass_rate - 1.0).abs() < 1e-9);
312    }
313}