1use std::fs;
2use std::path::Path;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::bean::{Bean, RunResult, Status};
8use crate::index::Index;
9
10#[derive(Debug, Serialize)]
16pub struct CostStats {
17 pub total_tokens: u64,
18 pub total_cost: f64,
19 pub avg_tokens_per_bean: f64,
20 pub first_pass_rate: f64,
22 pub overall_pass_rate: f64,
24 pub most_expensive_bean: Option<BeanRef>,
25 pub most_retried_bean: Option<BeanRef>,
26 pub beans_with_history: usize,
27}
28
29#[derive(Debug, Serialize)]
31pub struct BeanRef {
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_beans(beans_dir: &Path) -> Vec<Bean> {
57 let Ok(entries) = fs::read_dir(beans_dir) else {
58 return vec![];
59 };
60 let mut beans = 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_bean_file(filename) {
68 continue;
69 }
70 if let Ok(bean) = Bean::from_file(&path) {
71 beans.push(bean);
72 }
73 }
74 beans
75}
76
77fn is_bean_file(filename: &str) -> bool {
79 filename.ends_with(".yaml") || filename.ends_with(".md")
80}
81
82fn aggregate_cost(beans: &[Bean]) -> Option<CostStats> {
87 let mut total_tokens: u64 = 0;
88 let mut total_cost: f64 = 0.0;
89 let mut beans_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<(&Bean, u64)> = None;
101 let mut most_retried: Option<(&Bean, usize)> = None;
102
103 for bean in beans {
104 if bean.history.is_empty() {
105 continue;
106 }
107
108 beans_with_history += 1;
109 attempted += 1;
110
111 if bean.status == Status::Closed {
112 closed_count += 1;
113 }
114
115 let bean_tokens: u64 = bean.history.iter().filter_map(|r| r.tokens).sum();
117 let bean_cost: f64 = bean.history.iter().filter_map(|r| r.cost).sum();
118
119 total_tokens += bean_tokens;
120 total_cost += bean_cost;
121
122 if bean.status == Status::Closed {
124 closed_with_history += 1;
125 if bean
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 bean_tokens > 0 && most_expensive.is_none_or(|(_, t)| bean_tokens > t) {
137 most_expensive = Some((bean, bean_tokens));
138 }
139
140 let attempt_count = bean.history.len();
142 if attempt_count > 1 && most_retried.is_none_or(|(_, c)| attempt_count > c) {
143 most_retried = Some((bean, attempt_count));
144 }
145 }
146
147 if beans_with_history == 0 {
149 return None;
150 }
151
152 let avg_tokens_per_bean = if beans_with_history > 0 {
153 total_tokens as f64 / beans_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_bean,
174 first_pass_rate,
175 overall_pass_rate,
176 most_expensive_bean: most_expensive.map(|(b, tokens)| BeanRef {
177 id: b.id.clone(),
178 title: b.title.clone(),
179 value: tokens,
180 }),
181 most_retried_bean: most_retried.map(|(b, count)| BeanRef {
182 id: b.id.clone(),
183 title: b.title.clone(),
184 value: count as u64,
185 }),
186 beans_with_history,
187 })
188}
189
190pub fn cmd_stats(beans_dir: &Path, json: bool) -> Result<()> {
197 let index = Index::load_or_rebuild(beans_dir)?;
198
199 let total = index.beans.len();
201 let open = index
202 .beans
203 .iter()
204 .filter(|e| e.status == Status::Open)
205 .count();
206 let in_progress = index
207 .beans
208 .iter()
209 .filter(|e| e.status == Status::InProgress)
210 .count();
211 let closed = index
212 .beans
213 .iter()
214 .filter(|e| e.status == Status::Closed)
215 .count();
216
217 let blocked = index
219 .beans
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.beans.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.beans {
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_beans = load_all_beans(beans_dir);
255 let cost = aggregate_cost(&all_beans);
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!("=== Bean 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!("Beans tracked: {}", c.beans_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/bean: {:.0}", c.avg_tokens_per_bean);
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 bean) = c.most_expensive_bean {
304 println!();
305 println!(
306 "Most expensive: {} — {} ({} tokens)",
307 bean.id, bean.title, bean.value
308 );
309 }
310 if let Some(ref bean) = c.most_retried_bean {
311 println!(
312 "Most retried: {} — {} ({} attempts)",
313 bean.id, bean.title, bean.value
314 );
315 }
316 }
317
318 Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::bean::Bean;
325 use std::fs;
326 use tempfile::TempDir;
327
328 fn setup_test_beans() -> (TempDir, std::path::PathBuf) {
329 let dir = TempDir::new().unwrap();
330 let beans_dir = dir.path().join(".beans");
331 fs::create_dir(&beans_dir).unwrap();
332
333 let mut b1 = Bean::new("1", "Open P0");
335 b1.priority = 0;
336
337 let mut b2 = Bean::new("2", "In Progress P1");
338 b2.status = Status::InProgress;
339 b2.priority = 1;
340
341 let mut b3 = Bean::new("3", "Closed P2");
342 b3.status = Status::Closed;
343 b3.priority = 2;
344
345 let mut b4 = Bean::new("4", "Open P3");
346 b4.priority = 3;
347
348 let mut b5 = Bean::new("5", "Open depends on 1");
349 b5.dependencies = vec!["1".to_string()];
350
351 b1.to_file(beans_dir.join("1.yaml")).unwrap();
352 b2.to_file(beans_dir.join("2.yaml")).unwrap();
353 b3.to_file(beans_dir.join("3.yaml")).unwrap();
354 b4.to_file(beans_dir.join("4.yaml")).unwrap();
355 b5.to_file(beans_dir.join("5.yaml")).unwrap();
356
357 (dir, beans_dir)
358 }
359
360 #[test]
361 fn stats_calculates_counts() {
362 let (_dir, beans_dir) = setup_test_beans();
363 let index = Index::load_or_rebuild(&beans_dir).unwrap();
364
365 assert_eq!(
367 index
368 .beans
369 .iter()
370 .filter(|e| e.status == Status::Open)
371 .count(),
372 3
373 ); assert_eq!(
375 index
376 .beans
377 .iter()
378 .filter(|e| e.status == Status::InProgress)
379 .count(),
380 1
381 ); assert_eq!(
383 index
384 .beans
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, beans_dir) = setup_test_beans();
395 let result = cmd_stats(&beans_dir, false);
396 assert!(result.is_ok());
397 }
398
399 #[test]
400 fn stats_command_json() {
401 let (_dir, beans_dir) = setup_test_beans();
402 let result = cmd_stats(&beans_dir, true);
403 assert!(result.is_ok());
404 }
405
406 #[test]
407 fn empty_project() {
408 let dir = TempDir::new().unwrap();
409 let beans_dir = dir.path().join(".beans");
410 fs::create_dir(&beans_dir).unwrap();
411
412 let result = cmd_stats(&beans_dir, false);
413 assert!(result.is_ok());
414 }
415
416 #[test]
417 fn aggregate_cost_no_history() {
418 let beans = vec![Bean::new("1", "No history")];
419 let result = aggregate_cost(&beans);
420 assert!(
421 result.is_none(),
422 "Should return None when no beans have history"
423 );
424 }
425
426 #[test]
427 fn aggregate_cost_with_history() {
428 use crate::bean::{RunRecord, RunResult};
429 use chrono::Utc;
430
431 let mut bean = Bean::new("1", "With history");
432 bean.status = Status::Closed;
433 bean.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(&[bean]).unwrap();
447 assert_eq!(stats.total_tokens, 1000);
448 assert!((stats.total_cost - 0.05).abs() < 1e-9);
449 assert_eq!(stats.beans_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::bean::{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 = Bean::new("1", "Cheap bean");
473 cheap.history = vec![make_record(100, RunResult::Fail)];
474
475 let mut expensive = Bean::new("2", "Expensive bean");
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_bean.unwrap();
485 assert_eq!(exp.id, "2");
486 assert_eq!(exp.value, 8000);
487
488 let retried = stats.most_retried_bean.unwrap();
489 assert_eq!(retried.id, "2");
490 assert_eq!(retried.value, 2);
491 }
492}