Skip to main content

lean_ctx/tools/
ctx_gain.rs

1use crate::core::gain::GainEngine;
2
3pub fn handle(
4    action: &str,
5    period: Option<&str>,
6    model: Option<&str>,
7    limit: Option<usize>,
8) -> String {
9    let engine = GainEngine::load();
10    let lim = limit.unwrap_or(10).clamp(1, 50);
11    let env_model = std::env::var("LEAN_CTX_MODEL")
12        .or_else(|_| std::env::var("LCTX_MODEL"))
13        .ok();
14    let model = model.or(env_model.as_deref());
15
16    match action {
17        "status" | "report" | "" => format_summary(&engine, model),
18        "score" => format_score(&engine, model),
19        "tasks" => format_tasks(&engine),
20        "heatmap" => format_heatmap(&engine, lim),
21        "agents" => format_agents(&engine, lim),
22        "cost" => crate::core::a2a::cost_attribution::format_cost_report(&engine.costs, lim),
23        "wrapped" => render_wrapped(period.unwrap_or("all"), false),
24        "json" => format_json(&engine, model, lim),
25        _ => format!(
26            "Unknown action '{action}'. Available: status, report, score, cost, tasks, heatmap, wrapped, agents, json"
27        ),
28    }
29}
30
31pub(crate) fn render_wrapped(period: &str, compact: bool) -> String {
32    let report = crate::core::wrapped::WrappedReport::generate(period);
33    if compact {
34        report.format_compact()
35    } else {
36        report.format_ascii()
37    }
38}
39
40fn format_summary(engine: &GainEngine, model: Option<&str>) -> String {
41    let s = engine.summary(model);
42    let saved = format_tokens(s.tokens_saved);
43    let input = format_tokens(s.input_tokens);
44    let out = format_tokens(s.output_tokens);
45    let avoided = format_usd(s.avoided_usd);
46    let spend = format_usd(s.tool_spend_usd);
47    let roi = s
48        .roi
49        .map_or_else(|| "n/a".to_string(), |r| format!("{r:.2}x"));
50    let trend = match s.score.trend {
51        crate::core::gain::gain_score::Trend::Rising => "rising",
52        crate::core::gain::gain_score::Trend::Stable => "stable",
53        crate::core::gain::gain_score::Trend::Declining => "declining",
54    };
55
56    format!(
57        "lean-ctx gain\n\
58         ────────────\n\
59         Score: {total}/100  (compression {comp}, cost {cost}, quality {qual}, consistency {cons})  trend={trend}\n\
60         Tokens: {input} in → {out} out  | saved {saved}  ({rate:.1}%)\n\
61         Gain: {avoided} avoided  | tool spend {spend}  | ROI {roi}\n\
62         Pricing: model={model_key} ({match_kind:?}) in=${in_m:.2}/M out=${out_m:.2}/M\n",
63        total = s.score.total,
64        comp = s.score.compression,
65        cost = s.score.cost_efficiency,
66        qual = s.score.quality,
67        cons = s.score.consistency,
68        rate = s.gain_rate_pct,
69        model_key = s.model.model_key,
70        match_kind = s.model.match_kind,
71        in_m = s.model.cost.input_per_m,
72        out_m = s.model.cost.output_per_m,
73    )
74}
75
76fn format_score(engine: &GainEngine, model: Option<&str>) -> String {
77    let s = engine.summary(model);
78    format!(
79        "Gain Score: {}/100\n\
80         ──────────────────\n\
81         Compression:     {}/100\n\
82         Cost efficiency: {}/100\n\
83         Quality:         {}/100\n\
84         Consistency:     {}/100\n\
85         Trend:           {:?}\n",
86        s.score.total,
87        s.score.compression,
88        s.score.cost_efficiency,
89        s.score.quality,
90        s.score.consistency,
91        s.score.trend
92    )
93}
94
95fn format_tasks(engine: &GainEngine) -> String {
96    let rows = engine.task_breakdown();
97    if rows.is_empty() {
98        return "No task data yet.".to_string();
99    }
100    let mut lines = Vec::new();
101    lines.push("Task Breakdown (gain-first):".to_string());
102    lines.push(String::new());
103    for r in rows.iter().take(13) {
104        lines.push(format!(
105            "  {:<14}  saved {:>8} tok  cmds {:>5}  tools {:>5}  tool spend {}",
106            r.category.label(),
107            format_tokens(r.tokens_saved),
108            r.commands,
109            r.tool_calls,
110            format_usd(r.tool_spend_usd)
111        ));
112    }
113    lines.join("\n")
114}
115
116fn format_heatmap(engine: &GainEngine, limit: usize) -> String {
117    let rows = engine.heatmap_gains(limit);
118    if rows.is_empty() {
119        return "No heatmap data recorded yet.".to_string();
120    }
121    let mut lines = Vec::new();
122    lines.push(format!("Heatmap (top {limit} files by tokens saved):"));
123    for (i, r) in rows.iter().enumerate() {
124        lines.push(format!(
125            "  {}. {} — {} tok saved, {} accesses, {:.0}% compression",
126            i + 1,
127            r.path,
128            format_tokens(r.tokens_saved),
129            r.access_count,
130            r.compression_pct
131        ));
132    }
133    lines.join("\n")
134}
135
136fn format_agents(engine: &GainEngine, limit: usize) -> String {
137    let top = engine.costs.top_agents(limit);
138    if top.is_empty() {
139        return "No agent cost data recorded yet.".to_string();
140    }
141    let mut lines = Vec::new();
142    lines.push(format!("Top Agents by tool spend (top {limit}):"));
143    for (i, a) in top.iter().enumerate() {
144        lines.push(format!(
145            "  {}. {} ({}) — {} calls, {} in + {} out tok, {}{}",
146            i + 1,
147            a.agent_id,
148            a.agent_type,
149            a.total_calls,
150            format_tokens(a.total_input_tokens),
151            format_tokens(a.total_output_tokens),
152            format_usd(a.cost_usd),
153            a.model_key
154                .as_deref()
155                .map(|m| format!(" [{m}]"))
156                .unwrap_or_default()
157        ));
158    }
159    lines.join("\n")
160}
161
162fn format_json(engine: &GainEngine, model: Option<&str>, limit: usize) -> String {
163    #[derive(serde::Serialize)]
164    struct Payload {
165        summary: crate::core::gain::GainSummary,
166        tasks: Vec<crate::core::gain::TaskGainRow>,
167        heatmap: Vec<crate::core::gain::FileGainRow>,
168    }
169    let payload = Payload {
170        summary: engine.summary(model),
171        tasks: engine.task_breakdown(),
172        heatmap: engine.heatmap_gains(limit),
173    };
174    serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
175}
176
177fn format_tokens(tokens: u64) -> String {
178    if tokens >= 1_000_000 {
179        format!("{:.1}M", tokens as f64 / 1_000_000.0)
180    } else if tokens >= 1_000 {
181        format!("{:.1}K", tokens as f64 / 1_000.0)
182    } else {
183        format!("{tokens}")
184    }
185}
186
187fn format_usd(amount: f64) -> String {
188    if amount >= 0.01 {
189        format!("${amount:.2}")
190    } else {
191        format!("${amount:.3}")
192    }
193}