use std::path::Path;
use anyhow::Result;
use crate::inner_editor::InnerEditorStore;
use crate::inner_socrates::storage::InnerSocratesStore;
use crate::world::storage::WorldStore;
pub struct CostEntry {
pub name: String,
pub calls_today: i64,
pub daily_cap: i64,
}
pub struct CostReport {
pub day: String,
pub entries: Vec<CostEntry>,
}
impl CostReport {
pub fn total_calls(&self) -> i64 {
self.entries.iter().map(|e| e.calls_today).sum()
}
}
pub fn gather(
project: &Path,
day: &str,
world_cap: i64,
inner_cap: i64,
editor_cap: i64,
) -> CostReport {
let mut entries = vec![CostEntry {
name: "world fact-check (slow)".to_string(),
calls_today: WorldStore::open_for_project(project)
.ok()
.and_then(|s| s.llm_calls_today(day).ok())
.unwrap_or(0),
daily_cap: world_cap,
}];
let usage = InnerSocratesStore::open_for_project(project)
.ok()
.and_then(|s| s.llm_usage_today(day).ok())
.unwrap_or_default();
let mut have_slow = false;
let mut extra: Vec<(String, i64)> = Vec::new();
for (sub, calls) in usage {
if sub == InnerSocratesStore::SLOW_SUB_BUDGET {
have_slow = true;
entries.push(CostEntry {
name: "inner socrates (slow)".to_string(),
calls_today: calls,
daily_cap: inner_cap,
});
} else {
extra.push((sub, calls));
}
}
if !have_slow {
entries.push(CostEntry {
name: "inner socrates (slow)".to_string(),
calls_today: 0,
daily_cap: inner_cap,
});
}
for (sub, calls) in extra {
entries.push(CostEntry {
name: format!("inner socrates · {sub}"),
calls_today: calls,
daily_cap: inner_cap,
});
}
for (sub, calls) in InnerEditorStore::open_for_project(project)
.ok()
.and_then(|s| s.llm_usage_today(day).ok())
.unwrap_or_default()
{
let cap = if sub == InnerEditorStore::ENGAGEMENT_SUB_BUDGET { editor_cap } else { 0 };
entries.push(CostEntry { name: format!("inner editor · {sub}"), calls_today: calls, daily_cap: cap });
}
for (category, calls) in crate::ai::usage::usage_for(project, day) {
entries.push(CostEntry { name: category, calls_today: calls, daily_cap: 0 });
}
CostReport { day: day.to_string(), entries }
}
fn bar(used: i64, cap: i64) -> String {
if cap <= 0 {
return String::new();
}
const WIDTH: i64 = 20;
let used = used.max(0);
let filled = ((used * WIDTH) / cap).clamp(0, WIDTH);
let pct = (used * 100 / cap).clamp(0, 999);
format!(
"[{}{}] {pct}%",
"█".repeat(filled as usize),
"░".repeat((WIDTH - filled) as usize)
)
}
pub fn render_lines(report: &CostReport) -> Vec<String> {
let mut out = vec![format!("AI cost — LLM calls today ({})", report.day), String::new()];
out.push(" daily budgets (informative):".into());
for e in report.entries.iter().filter(|e| e.daily_cap > 0) {
out.push(format!(
" {:<24} {:>3} / {:<3} {}",
e.name,
e.calls_today,
e.daily_cap,
bar(e.calls_today, e.daily_cap)
));
}
let other: Vec<&CostEntry> = report.entries.iter().filter(|e| e.daily_cap <= 0).collect();
if other.is_empty() {
out.push(" other AI calls today: (none yet)".into());
} else {
out.push(String::new());
out.push(" other AI calls today:".into());
for e in other {
out.push(format!(" {:<24} {:>3}", e.name, e.calls_today));
}
}
out.push(String::new());
out.push(format!(" {:<24} {:>3}", "total AI calls today", report.total_calls()));
out.push(String::new());
out.push(" Budgets are informative, not limits — past a budget the slow tracks".into());
out.push(" warn and continue. The tally resets per goals.day_boundary. (CLI: inkhaven cost)".into());
out
}
pub fn run(project: &Path) -> Result<()> {
let layout = crate::project::ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = crate::config::Config::load_layered(&layout.config_path())?;
crate::dayclock::set_boundary(cfg.goals.day_boundary);
let day = crate::dayclock::today_key();
let report = gather(
project,
&day,
cfg.cost.world_daily_call_cap,
cfg.cost.inner_socrates_daily_call_cap,
cfg.inner_editor.llm.editor_engagement.max_calls_per_day,
);
for line in render_lines(&report) {
println!("{line}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bar_clamps_and_percents() {
assert!(bar(0, 200).contains("0%"));
assert!(bar(100, 200).contains("50%"));
let over = bar(300, 200);
assert!(over.contains("150%"), "got {over}");
assert!(!over.contains('░'), "over-cap bar is full");
assert_eq!(bar(5, 0), "", "a zero cap renders no bar");
}
#[test]
fn report_totals_and_renders() {
let r = CostReport {
day: "2026-06-24".into(),
entries: vec![
CostEntry { name: "world fact-check (slow)".into(), calls_today: 3, daily_cap: 200 },
CostEntry { name: "grammar".into(), calls_today: 7, daily_cap: 0 },
],
};
assert_eq!(r.total_calls(), 10);
let lines = render_lines(&r);
assert!(lines.iter().any(|l| l.contains("2026-06-24")));
assert!(lines.iter().any(|l| l.contains("daily budgets")));
assert!(lines.iter().any(|l| l.contains("other AI calls")));
assert!(lines.iter().any(|l| l.contains("grammar")));
assert!(lines.iter().any(|l| l.contains("total AI calls today")));
assert!(lines.iter().any(|l| l.contains("10")));
assert!(lines.iter().any(|l| l.to_lowercase().contains("informative")));
let grammar_line = lines.iter().find(|l| l.contains("grammar")).unwrap();
assert!(!grammar_line.contains('['), "uncapped entries have no bar");
}
}