use std::fs;
use std::path::Path;
use anyhow::Result;
use serde::Serialize;
use crate::index::Index;
use crate::unit::{RunResult, Status, Unit};
#[derive(Debug, Serialize)]
pub struct CostStats {
pub total_tokens: u64,
pub total_cost: f64,
pub avg_tokens_per_unit: f64,
pub first_pass_rate: f64,
pub overall_pass_rate: f64,
pub most_expensive_unit: Option<UnitRef>,
pub most_retried_unit: Option<UnitRef>,
pub units_with_history: usize,
}
#[derive(Debug, Serialize)]
pub struct UnitRef {
pub id: String,
pub title: String,
pub value: u64,
}
#[derive(Debug, Serialize)]
pub struct StatsResult {
pub total: usize,
pub open: usize,
pub in_progress: usize,
pub closed: usize,
pub blocked: usize,
pub completion_pct: f64,
pub priority_counts: [usize; 5],
pub cost: Option<CostStats>,
}
fn load_all_units(mana_dir: &Path) -> Vec<Unit> {
let Ok(entries) = fs::read_dir(mana_dir) else {
return vec![];
};
let mut units = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !(filename.ends_with(".yaml") || filename.ends_with(".md")) {
continue;
}
if let Ok(unit) = Unit::from_file(&path) {
units.push(unit);
}
}
units
}
pub fn aggregate_cost(units: &[Unit]) -> Option<CostStats> {
let mut total_tokens: u64 = 0;
let mut total_cost: f64 = 0.0;
let mut units_with_history: usize = 0;
let mut closed_with_history: usize = 0;
let mut first_pass_count: usize = 0;
let mut attempted: usize = 0;
let mut closed_count: usize = 0;
let mut most_expensive: Option<(&Unit, u64)> = None;
let mut most_retried: Option<(&Unit, usize)> = None;
for unit in units {
if unit.history.is_empty() {
continue;
}
units_with_history += 1;
attempted += 1;
if unit.status == Status::Closed {
closed_count += 1;
}
let unit_tokens: u64 = unit.history.iter().filter_map(|r| r.tokens).sum();
let unit_cost: f64 = unit.history.iter().filter_map(|r| r.cost).sum();
total_tokens += unit_tokens;
total_cost += unit_cost;
if unit.status == Status::Closed {
closed_with_history += 1;
if unit
.history
.first()
.map(|r| r.result == RunResult::Pass)
.unwrap_or(false)
{
first_pass_count += 1;
}
}
if unit_tokens > 0 && most_expensive.is_none_or(|(_, t)| unit_tokens > t) {
most_expensive = Some((unit, unit_tokens));
}
let attempt_count = unit.history.len();
if attempt_count > 1 && most_retried.is_none_or(|(_, c)| attempt_count > c) {
most_retried = Some((unit, attempt_count));
}
}
if units_with_history == 0 {
return None;
}
let avg_tokens_per_unit = if units_with_history > 0 {
total_tokens as f64 / units_with_history as f64
} else {
0.0
};
let first_pass_rate = if closed_with_history > 0 {
first_pass_count as f64 / closed_with_history as f64
} else {
0.0
};
let overall_pass_rate = if attempted > 0 {
closed_count as f64 / attempted as f64
} else {
0.0
};
Some(CostStats {
total_tokens,
total_cost,
avg_tokens_per_unit,
first_pass_rate,
overall_pass_rate,
most_expensive_unit: most_expensive.map(|(b, tokens)| UnitRef {
id: b.id.clone(),
title: b.title.clone(),
value: tokens,
}),
most_retried_unit: most_retried.map(|(b, count)| UnitRef {
id: b.id.clone(),
title: b.title.clone(),
value: count as u64,
}),
units_with_history,
})
}
pub fn stats(mana_dir: &Path) -> Result<StatsResult> {
let index = Index::load_or_rebuild(mana_dir)?;
let total = index.units.len();
let open = index
.units
.iter()
.filter(|e| e.status == Status::Open)
.count();
let in_progress = index
.units
.iter()
.filter(|e| e.status == Status::InProgress)
.count();
let closed = index
.units
.iter()
.filter(|e| e.status == Status::Closed)
.count();
let blocked = index
.units
.iter()
.filter(|e| {
if e.status != Status::Open {
return false;
}
for dep_id in &e.dependencies {
if let Some(dep) = index.units.iter().find(|d| &d.id == dep_id) {
if dep.status != Status::Closed {
return true;
}
} else {
return true;
}
}
false
})
.count();
let mut priority_counts = [0usize; 5];
for entry in &index.units {
if (entry.priority as usize) < 5 {
priority_counts[entry.priority as usize] += 1;
}
}
let completion_pct = if total > 0 {
(closed as f64 / total as f64) * 100.0
} else {
0.0
};
let all_units = load_all_units(mana_dir);
let cost = aggregate_cost(&all_units);
Ok(StatsResult {
total,
open,
in_progress,
closed,
blocked,
completion_pct,
priority_counts,
cost,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::unit::{RunRecord, RunResult, Unit};
use chrono::Utc;
use std::fs;
use tempfile::TempDir;
fn setup_test_units() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let mut b1 = Unit::new("1", "Open P0");
b1.priority = 0;
let mut b2 = Unit::new("2", "In Progress P1");
b2.status = Status::InProgress;
b2.priority = 1;
let mut b3 = Unit::new("3", "Closed P2");
b3.status = Status::Closed;
b3.priority = 2;
b1.to_file(mana_dir.join("1.yaml")).unwrap();
b2.to_file(mana_dir.join("2.yaml")).unwrap();
b3.to_file(mana_dir.join("3.yaml")).unwrap();
(dir, mana_dir)
}
#[test]
fn stats_computes_counts() {
let (_dir, mana_dir) = setup_test_units();
let result = stats(&mana_dir).unwrap();
assert_eq!(result.total, 3);
assert_eq!(result.open, 1);
assert_eq!(result.in_progress, 1);
assert_eq!(result.closed, 1);
}
#[test]
fn stats_empty_project() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let result = stats(&mana_dir).unwrap();
assert_eq!(result.total, 0);
assert_eq!(result.completion_pct, 0.0);
}
#[test]
fn aggregate_cost_no_history() {
let units = vec![Unit::new("1", "No history")];
let result = aggregate_cost(&units);
assert!(result.is_none());
}
#[test]
fn aggregate_cost_with_history() {
let mut unit = Unit::new("1", "With history");
unit.status = Status::Closed;
unit.history = vec![RunRecord {
attempt: 1,
started_at: Utc::now(),
finished_at: None,
duration_secs: None,
agent: None,
result: RunResult::Pass,
exit_code: Some(0),
tokens: Some(1000),
cost: Some(0.05),
output_snippet: None,
autonomy_observation: None,
}];
let stats = aggregate_cost(&[unit]).unwrap();
assert_eq!(stats.total_tokens, 1000);
assert!((stats.total_cost - 0.05).abs() < 1e-9);
assert!((stats.first_pass_rate - 1.0).abs() < 1e-9);
}
}