inkhaven 1.4.8

Inkhaven — TUI literary work editor for Typst books
//! `inkhaven cost` — the unified AI cost dashboard (road to 1.4.0).
//!
//! The LLM-using subsystems each track their own daily call budget in their own
//! store. This reads them into one view: per-budget calls-today vs the daily cap,
//! plus a note on the per-run-only timeline elaboration. Read-only aggregation; it
//! changes no caps and enforces nothing. The `render_lines` output is shared by the
//! CLI here and the TUI panel (P1).

use std::path::Path;

use anyhow::Result;

use crate::inner_editor::InnerEditorStore;
use crate::inner_socrates::storage::InnerSocratesStore;
use crate::world::storage::WorldStore;

/// One persisted daily-capped LLM budget. The name is owned so the dashboard can
/// enumerate dynamically-keyed analytical-thread sub-budgets, not just a fixed set.
pub struct CostEntry {
    pub name: String,
    pub calls_today: i64,
    pub daily_cap: i64,
}

pub struct CostReport {
    /// `YYYY-MM-DD` the tallies are for.
    pub day: String,
    pub entries: Vec<CostEntry>,
}

impl CostReport {
    pub fn total_calls(&self) -> i64 {
        self.entries.iter().map(|e| e.calls_today).sum()
    }
}

/// Read each subsystem's call tally for `day` (gracefully zero when a store is
/// absent). The caps come from the stores' shared consts, so they match the
/// preflights exactly.
///
/// Extensible by design: the world slow track is a single budget, but the Inner
/// Socrates store keys usage by **sub-budget**, so every analytical thread that
/// records a cost-capped slow pass under its own key appears here automatically.
/// To surface a new analytical thread's AI cost, record its calls via
/// `InnerSocratesStore::record_llm_call(day, "<thread-key>")` — no dashboard change
/// needed.
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,
    }];

    // Inner Socrates: the canonical slow track always shown, plus any other
    // analytical-thread sub-budgets recorded today.
    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,
        });
    }

    // INNER_EDITOR-1 — the Editor's own per-feature store sub-budgets
    // (editor_engagement / conversation), parallel to Inner Socrates. The
    // engagement cap is informative; conversation rides the ai::usage tally too.
    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 });
    }

    // Every other AI call, by category (chat / grammar / explain / …) — uncapped
    // (`daily_cap` 0), recorded by the inference chokepoint. The slow tracks above
    // aren't double-counted here (they opt out of the usage tally).
    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 }
}

/// A 20-cell usage bar + percentage for a `used / cap` budget.
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)
    )
}

/// Render the report as lines (shared by the CLI + the TUI panel).
pub fn render_lines(report: &CostReport) -> Vec<String> {
    let mut out = vec![format!("AI cost — LLM calls today ({})", report.day), String::new()];

    // Capped budgets (informative — see the note below).
    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)
        ));
    }

    // Every other AI call, by category (uncapped) — count only.
    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%"));
        // Over-cap: the bar fills completely but the percentage reports the truth.
        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 },
                // An uncapped per-category AI call (e.g. grammar) — count only.
                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")));
        // The informative-not-a-limit framing must be present.
        assert!(lines.iter().any(|l| l.to_lowercase().contains("informative")));
        // An uncapped entry shows a count but no usage bar.
        let grammar_line = lines.iter().find(|l| l.contains("grammar")).unwrap();
        assert!(!grammar_line.contains('['), "uncapped entries have no bar");
    }
}