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)]
12pub struct CostStats {
13 pub total_tokens: u64,
14 pub total_cost: f64,
15 pub avg_tokens_per_unit: f64,
16 pub first_pass_rate: f64,
18 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#[derive(Debug, Serialize)]
27pub struct UnitRef {
28 pub id: String,
29 pub title: String,
30 pub value: u64,
31}
32
33#[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
46fn 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
68pub 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
162pub 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}