1use std::collections::HashMap;
2
3use crate::core::tokens::count_tokens;
4
5const COMPRESSIBLE_COMMANDS: &[(&str, &str, &str)] = &[
6 ("git", "git status/diff/log/add/commit/push", "80-95%"),
7 ("cargo", "cargo build/test/clippy", "80-95%"),
8 ("npm", "npm install/run/test", "60-85%"),
9 ("pnpm", "pnpm install/run/test", "60-85%"),
10 ("yarn", "yarn install/run/test", "60-85%"),
11 ("docker", "docker ps/images/logs/build", "60-80%"),
12 ("kubectl", "kubectl get/describe/logs", "60-80%"),
13 ("pip", "pip install/list/freeze", "60-85%"),
14 ("go", "go test/build/vet", "75-90%"),
15 ("ruff", "ruff check/format", "80-90%"),
16 ("eslint", "eslint/biome lint", "80-90%"),
17 ("prettier", "prettier --check", "70-80%"),
18 ("tsc", "TypeScript compiler", "80-90%"),
19 ("curl", "HTTP requests", "60-80%"),
20 ("grep", "grep/rg search", "50-80%"),
21 ("find", "find files", "50-70%"),
22 ("ls", "directory listing", "50-70%"),
23 ("pytest", "Python tests", "85-95%"),
24 ("rspec", "Ruby tests", "60-80%"),
25 ("aws", "AWS CLI", "60-80%"),
26 ("helm", "Kubernetes Helm", "60-80%"),
27 ("terraform", "Terraform", "60-80%"),
28 ("ansible", "Ansible", "60-80%"),
29 ("prisma", "Prisma ORM", "70-85%"),
30 ("cmake", "CMake build", "60-80%"),
31 ("bazel", "Bazel build", "60-80%"),
32 ("zig", "Zig build/test", "60-80%"),
33 ("swift", "Swift build/test", "60-80%"),
34 ("deno", "Deno runtime", "60-80%"),
35 ("bun", "Bun runtime", "60-80%"),
36 ("composer", "PHP Composer", "60-80%"),
37 ("mix", "Elixir Mix", "60-80%"),
38 ("php", "PHP CLI/artisan", "60-80%"),
39];
40
41pub struct DiscoverResult {
42 pub total_commands: u32,
43 pub already_optimized: u32,
44 pub missed_commands: Vec<MissedCommand>,
45 pub potential_tokens: usize,
46 pub potential_usd: f64,
47}
48
49pub struct MissedCommand {
50 pub prefix: String,
51 pub description: String,
52 pub savings_range: String,
53 pub count: u32,
54 pub estimated_tokens: usize,
55}
56
57pub fn analyze_history(history: &[String], limit: usize) -> DiscoverResult {
58 let mut missed: HashMap<&str, u32> = HashMap::new();
59 let mut already_optimized = 0u32;
60 let mut total_commands = 0u32;
61
62 for cmd in history {
63 let trimmed = cmd.trim();
64 if trimmed.is_empty() {
65 continue;
66 }
67 total_commands += 1;
68
69 if trimmed.starts_with("lean-ctx ") {
70 already_optimized += 1;
71 continue;
72 }
73
74 for (prefix, _, _) in COMPRESSIBLE_COMMANDS {
75 if trimmed.starts_with(prefix) || trimmed.starts_with(&format!("{prefix} ")) {
76 *missed.entry(prefix).or_insert(0) += 1;
77 break;
78 }
79 }
80 }
81
82 let mut sorted: Vec<_> = missed.into_iter().collect();
83 sorted.sort_by_key(|x| std::cmp::Reverse(x.1));
84
85 let total_missed: u32 = sorted.iter().map(|(_, c)| c).sum();
86 let est_tokens_per_cmd = 500;
87 let est_savings_pct = 0.75;
88 let potential = (total_missed as f64 * est_tokens_per_cmd as f64 * est_savings_pct) as usize;
89 let potential_usd =
90 potential as f64 * crate::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
91
92 let real_stats = crate::core::stats::load();
93 let (effective_potential, effective_usd) = if real_stats.total_commands > 0 {
94 let real_savings_rate = if real_stats.total_input_tokens > 0 {
95 1.0 - (real_stats.total_output_tokens as f64 / real_stats.total_input_tokens as f64)
96 } else {
97 est_savings_pct
98 };
99 let p = (total_missed as f64 * est_tokens_per_cmd as f64 * real_savings_rate) as usize;
100 let u = p as f64 * crate::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
101 (p, u)
102 } else {
103 (potential, potential_usd)
104 };
105
106 let missed_commands = sorted
107 .into_iter()
108 .take(limit)
109 .map(|(prefix, count)| {
110 let (desc, savings) = COMPRESSIBLE_COMMANDS
111 .iter()
112 .find(|(p, _, _)| p == &prefix)
113 .map(|(_, d, s)| (d.to_string(), s.to_string()))
114 .unwrap_or_default();
115 MissedCommand {
116 prefix: prefix.to_string(),
117 description: desc,
118 savings_range: savings,
119 count,
120 estimated_tokens: (count as f64 * est_tokens_per_cmd as f64 * est_savings_pct)
121 as usize,
122 }
123 })
124 .collect();
125
126 DiscoverResult {
127 total_commands,
128 already_optimized,
129 missed_commands,
130 potential_tokens: effective_potential,
131 potential_usd: effective_usd,
132 }
133}
134
135pub fn discover_from_history(history: &[String], limit: usize) -> String {
136 let result = analyze_history(history, limit);
137
138 if result.missed_commands.is_empty() {
139 return format!(
140 "No missed savings found in last {} commands. \
141 {} already optimized.",
142 result.total_commands, result.already_optimized
143 );
144 }
145
146 let mut lines = Vec::new();
147 lines.push(format!(
148 "Analyzed {} commands ({} already optimized):",
149 result.total_commands, result.already_optimized
150 ));
151 lines.push(String::new());
152
153 let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
154 lines.push(format!(
155 "{total_missed} commands could benefit from lean-ctx:"
156 ));
157 lines.push(String::new());
158
159 for m in &result.missed_commands {
160 lines.push(format!(
161 " {:>4}x {:<12} {} ({})",
162 m.count, m.prefix, m.description, m.savings_range
163 ));
164 }
165
166 lines.push(String::new());
167 lines.push(format!(
168 "Estimated potential: ~{} tokens saved (~${:.2})",
169 result.potential_tokens, result.potential_usd
170 ));
171 lines.push(String::new());
172 lines.push("Fix: run 'lean-ctx init --global' to auto-compress all commands.".to_string());
173 lines.push("Or: run 'lean-ctx init --agent <tool>' for AI tool hooks.".to_string());
174
175 let output = lines.join("\n");
176 let tokens = count_tokens(&output);
177 format!("{output}\n\n[{tokens} tok]")
178}
179
180pub fn format_cli_output(result: &DiscoverResult) -> String {
181 if result.missed_commands.is_empty() {
182 return format!(
183 "All compressible commands are already using lean-ctx!\n\
184 ({} commands analyzed, {} via lean-ctx)",
185 result.total_commands, result.already_optimized
186 );
187 }
188
189 let mut lines = Vec::new();
190 let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
191
192 lines.push(format!(
193 "Found {total_missed} compressible commands not using lean-ctx:\n"
194 ));
195 lines.push(format!(
196 " {:<14} {:>5} {:>10} {:<30} {}",
197 "COMMAND", "COUNT", "SAVINGS", "DESCRIPTION", "EST. TOKENS"
198 ));
199 lines.push(format!(" {}", "-".repeat(80)));
200
201 for m in &result.missed_commands {
202 lines.push(format!(
203 " {:<14} {:>5}x {:>10} {:<30} ~{}",
204 m.prefix, m.count, m.savings_range, m.description, m.estimated_tokens
205 ));
206 }
207
208 lines.push(String::new());
209 lines.push(format!(
210 "Estimated missed savings: ~{} tokens (~${:.2}/month at current rate)",
211 result.potential_tokens,
212 result.potential_usd * 30.0
213 ));
214 lines.push(format!(
215 "Already using lean-ctx: {} commands",
216 result.already_optimized
217 ));
218 lines.push(String::new());
219 lines.push("Run 'lean-ctx init --global' to enable compression for all commands.".to_string());
220
221 lines.join("\n")
222}
223
224pub fn render_before_card(result: &DiscoverResult) -> String {
230 let saved = crate::core::wrapped::format_tokens(result.potential_tokens as u64);
231 let monthly_usd = result.potential_usd * 30.0;
232 let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
233 let top = before_card_top_commands(result);
234 format!(
235 r##"<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
236 <defs>
237 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
238 <stop offset="0" stop-color="#0b1020"/>
239 <stop offset="1" stop-color="#131a2e"/>
240 </linearGradient>
241 <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
242 <stop offset="0" stop-color="#f59e0b"/>
243 <stop offset="1" stop-color="#ef4444"/>
244 </linearGradient>
245 </defs>
246 <rect width="1200" height="630" fill="url(#bg)"/>
247 <rect x="0" y="0" width="1200" height="8" fill="url(#accent)"/>
248 <text x="70" y="92" fill="#e5e7eb" font-size="34" font-weight="700">lean-ctx <tspan fill="#f59e0b">Ghost Tokens</tspan></text>
249 <text x="70" y="130" fill="#94a3b8" font-size="24">before lean-ctx — estimated from my shell history</text>
250 <text x="70" y="300" fill="#f59e0b" font-size="120" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{saved}</text>
251 <text x="76" y="346" fill="#94a3b8" font-size="26">tokens/month left on the table</text>
252 <text x="70" y="430" fill="#e5e7eb" font-size="60" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">${monthly_usd:.0}</text>
253 <text x="74" y="462" fill="#94a3b8" font-size="22">potential monthly savings</text>
254 <text x="70" y="512" fill="#cbd5e1" font-size="22">{total_missed} uncompressed commands · {already} already via lean-ctx</text>
255{top}
256 <text x="70" y="600" fill="#475569" font-size="17">Estimate from local shell history · run `lean-ctx setup` to stop the leak</text>
257 <text x="1130" y="600" text-anchor="end" fill="#f59e0b" font-size="26" font-weight="700">leanctx.com</text>
258</svg>"##,
259 already = result.already_optimized,
260 )
261}
262
263fn before_card_top_commands(result: &DiscoverResult) -> String {
265 if result.missed_commands.is_empty() {
266 return String::new();
267 }
268 let joined = result
269 .missed_commands
270 .iter()
271 .take(3)
272 .map(|m| format!("{} {}x", m.prefix, m.count))
273 .collect::<Vec<_>>()
274 .join(" · ");
275 format!(
276 " <text x=\"70\" y=\"556\" fill=\"#cbd5e1\" font-size=\"22\">top missed {}</text>",
277 xml_escape(&joined)
278 )
279}
280
281fn xml_escape(s: &str) -> String {
283 s.replace('&', "&")
284 .replace('<', "<")
285 .replace('>', ">")
286 .replace('"', """)
287 .replace('\'', "'")
288}
289
290#[cfg(test)]
291mod tests {
292 use super::{analyze_history, render_before_card};
293
294 fn history() -> Vec<String> {
295 vec![
296 "git status".into(),
297 "git diff".into(),
298 "cargo build".into(),
299 "cargo test".into(),
300 "lean-ctx gain".into(),
301 "vim notes.txt".into(),
302 ]
303 }
304
305 #[test]
306 fn before_card_is_well_formed_and_branded() {
307 let result = analyze_history(&history(), 20);
308 let svg = render_before_card(&result);
309 assert!(svg.starts_with("<svg"), "must be an SVG document");
310 assert!(svg.trim_end().ends_with("</svg>"), "must close the svg tag");
311 assert!(svg.contains("leanctx.com"), "must carry the brand footer");
312 assert!(svg.contains("Ghost Tokens"), "must frame the leak");
313 assert!(
314 svg.contains("tokens/month left on the table"),
315 "headline label present"
316 );
317 }
318
319 #[test]
320 fn xml_escape_neutralizes_markup() {
321 assert_eq!(super::xml_escape("a<b>&\"'"), "a<b>&"'");
322 }
323}