Skip to main content

bn/commands/
stats.rs

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// ---------------------------------------------------------------------------
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_bean: f64,
20    /// Rate at which closed beans passed on their first attempt (0.0–1.0).
21    pub first_pass_rate: f64,
22    /// Rate at which attempted beans eventually closed (0.0–1.0).
23    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/// Lightweight bean reference for reporting.
30#[derive(Debug, Serialize)]
31pub struct BeanRef {
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// Bean file discovery
52// ---------------------------------------------------------------------------
53
54/// Returns all beans loaded from YAML files in `beans_dir` (non-recursive,
55/// skips files that don't look like bean files or fail to parse).
56fn 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
77/// Returns true for files that look like bean YAML files.
78fn is_bean_file(filename: &str) -> bool {
79    filename.ends_with(".yaml") || filename.ends_with(".md")
80}
81
82// ---------------------------------------------------------------------------
83// Aggregation
84// ---------------------------------------------------------------------------
85
86fn 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    // For first-pass rate: closed beans 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<(&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        // Accumulate tokens/cost from all RunRecords
116        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        // First-pass rate: closed beans where first RunRecord is a Pass
123        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        // Track most expensive (by total tokens across all attempts)
136        if bean_tokens > 0 && most_expensive.is_none_or(|(_, t)| bean_tokens > t) {
137            most_expensive = Some((bean, bean_tokens));
138        }
139
140        // Track most retried (by number of history entries)
141        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    // Don't show the section at all when nothing has been tracked
148    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
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(beans_dir: &Path, json: bool) -> Result<()> {
197    let index = Index::load_or_rebuild(beans_dir)?;
198
199    // Count by status
200    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    // Count blocked (open with unresolved dependencies)
218    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    // Count by priority
239    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    // 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 bean files
254    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    // Human-readable output
273    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        // Create beans with different statuses and priorities
334        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        // Verify counts
366        assert_eq!(
367            index
368                .beans
369                .iter()
370                .filter(|e| e.status == Status::Open)
371                .count(),
372            3
373        ); // 1, 4, 5
374        assert_eq!(
375            index
376                .beans
377                .iter()
378                .filter(|e| e.status == Status::InProgress)
379                .count(),
380            1
381        ); // 2
382        assert_eq!(
383            index
384                .beans
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, 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}