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}