1use std::collections::HashMap;
2
3use crate::core::stats::StatsStore;
4use crate::core::tokens::count_tokens;
5use crate::shell::output_policy::{OutputPolicy, classify};
6
7const COMPRESSIBLE_FAMILIES: &[(&str, &str)] = &[
13 ("git", "git status/diff/log/commit/push"),
14 ("gh", "GitHub CLI"),
15 ("glab", "GitLab CLI"),
16 ("cargo", "cargo build/test/clippy"),
17 ("npm", "npm install/run/test"),
18 ("pnpm", "pnpm install/run/test"),
19 ("yarn", "yarn install/run/test"),
20 ("bun", "Bun runtime"),
21 ("deno", "Deno runtime"),
22 ("docker", "docker ps/images/logs/build"),
23 ("kubectl", "kubectl get/describe/logs"),
24 ("helm", "Kubernetes Helm"),
25 ("pip", "pip install/list/freeze"),
26 ("poetry", "Poetry"),
27 ("uv", "uv add/lock/sync"),
28 ("conda", "conda/mamba env"),
29 ("pipx", "pipx install"),
30 ("go", "go test/build/vet"),
31 ("mypy", "mypy type check"),
32 ("pyright", "pyright type check"),
33 ("ruff", "ruff check/format"),
34 ("eslint", "eslint/biome lint"),
35 ("prettier", "prettier --check"),
36 ("tsc", "TypeScript compiler"),
37 ("pytest", "Python tests"),
38 ("jest", "Jest tests"),
39 ("vitest", "Vitest tests"),
40 ("mocha", "Mocha tests"),
41 ("playwright", "Playwright tests"),
42 ("rspec", "Ruby tests"),
43 ("rubocop", "RuboCop lint"),
44 ("bundle", "Bundler"),
45 ("rake", "Rake tasks"),
46 ("curl", "HTTP requests"),
47 ("wget", "HTTP downloads"),
48 ("grep", "grep/rg search"),
49 ("rg", "ripgrep search"),
50 ("find", "find files"),
51 ("fd", "fd file search"),
52 ("ls", "directory listing"),
53 ("jq", "JSON processing"),
54 ("aws", "AWS CLI"),
55 ("terraform", "Terraform"),
56 ("tofu", "OpenTofu"),
57 ("pulumi", "Pulumi IaC"),
58 ("ansible", "Ansible"),
59 ("prisma", "Prisma ORM"),
60 ("psql", "PostgreSQL"),
61 ("mysql", "MySQL/MariaDB"),
62 ("cmake", "CMake build"),
63 ("ninja", "Ninja build"),
64 ("bazel", "Bazel build"),
65 ("make", "Make targets"),
66 ("just", "just recipes"),
67 ("mvn", "Maven build"),
68 ("gradle", "Gradle build"),
69 ("dotnet", "dotnet build/test"),
70 ("flutter", "Flutter build"),
71 ("swift", "Swift build/test"),
72 ("zig", "Zig build/test"),
73 ("composer", "PHP Composer"),
74 ("mix", "Elixir Mix"),
75 ("next", "Next.js build"),
76 ("vite", "Vite build"),
77 ("turbo", "Turborepo"),
78 ("nx", "Nx monorepo"),
79 ("systemctl", "systemd units"),
80 ("journalctl", "systemd logs"),
81 ("dbt", "dbt models/tests"),
82 ("alembic", "Alembic migrations"),
83 ("flyway", "Flyway migrations"),
84 ("ollama", "Ollama models"),
85 ("mlflow", "MLflow runs"),
86 ("semgrep", "Semgrep scan"),
87 ("trivy", "Trivy scan"),
88 ("grype", "Grype scan"),
89 ("syft", "Syft SBOM"),
90 ("cosign", "Cosign verify"),
91 ("swiftlint", "SwiftLint"),
92 ("jj", "Jujutsu VCS"),
93 ("mise", "mise toolchain"),
94 ("buf", "Protobuf buf"),
95 ("gem", "RubyGems"),
96 ("linkerd", "Linkerd check"),
97 ("argocd", "Argo CD"),
98 ("vercel", "Vercel deploy"),
99 ("fly", "Fly.io deploy"),
100 ("wrangler", "Cloudflare deploy"),
101 ("skaffold", "Skaffold"),
102 ("supabase", "Supabase"),
103];
104
105pub struct DiscoverResult {
106 pub total_commands: u32,
107 pub already_optimized: u32,
108 pub missed_commands: Vec<MissedCommand>,
109 pub potential_tokens: usize,
110 pub potential_usd: f64,
111 pub has_measured_data: bool,
115}
116
117pub struct MissedCommand {
118 pub prefix: String,
119 pub description: String,
120 pub savings_range: String,
123 pub count: u32,
124 pub estimated_tokens: usize,
125 pub measured: bool,
126}
127
128fn base_of(command: &str) -> &str {
130 let first = command.split_whitespace().next().unwrap_or(command);
131 std::path::Path::new(first)
132 .file_name()
133 .and_then(|n| n.to_str())
134 .unwrap_or(first)
135}
136
137fn describe(base: &str) -> Option<&'static str> {
138 COMPRESSIBLE_FAMILIES
139 .iter()
140 .find(|(b, _)| *b == base)
141 .map(|(_, d)| *d)
142}
143
144fn measured_by_family(store: &StatsStore) -> HashMap<String, (u64, u64, u64)> {
147 let mut by_base: HashMap<String, (u64, u64, u64)> = HashMap::new();
148 for (key, s) in &store.commands {
149 let entry = by_base.entry(base_of(key).to_string()).or_default();
150 entry.0 = entry.0.saturating_add(s.input_tokens);
151 entry.1 = entry.1.saturating_add(s.output_tokens);
152 entry.2 = entry.2.saturating_add(s.count);
153 }
154 by_base
155}
156
157pub fn analyze_history(history: &[String], limit: usize) -> DiscoverResult {
158 let store = crate::core::stats::load();
159 analyze_history_with_stats(history, limit, &store)
160}
161
162fn analyze_history_with_stats(
165 history: &[String],
166 limit: usize,
167 store: &StatsStore,
168) -> DiscoverResult {
169 let mut missed: HashMap<String, u32> = HashMap::new();
170 let mut already_optimized = 0u32;
171 let mut total_commands = 0u32;
172
173 let measured = measured_by_family(store);
174
175 for cmd in history {
176 let trimmed = cmd.trim();
177 if trimmed.is_empty() {
178 continue;
179 }
180 total_commands += 1;
181
182 if trimmed.starts_with("lean-ctx ") || trimmed.starts_with("lean-ctx\t") {
183 already_optimized += 1;
184 continue;
185 }
186
187 let base = base_of(trimmed);
188 let known = describe(base).is_some() || measured.contains_key(base);
192 if known && classify(trimmed, &[]) == OutputPolicy::Compressible {
193 *missed.entry(base.to_string()).or_insert(0) += 1;
194 }
195 }
196
197 let mut sorted: Vec<_> = missed.into_iter().collect();
198 sorted.sort_by_key(|x| std::cmp::Reverse(x.1));
199
200 let price_per_tok = crate::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
201 let mut potential_tokens = 0usize;
202 let mut has_measured_data = false;
203
204 let missed_commands: Vec<MissedCommand> = sorted
205 .into_iter()
206 .take(limit)
207 .map(|(base, count)| {
208 let description = describe(&base).unwrap_or("compressible output").to_string();
209 let (savings_range, estimated_tokens, is_measured) = match measured.get(&base) {
213 Some(&(input, output, cnt)) if input > 0 && cnt > 0 => {
214 let rate = 1.0 - (output as f64 / input as f64);
215 let avg_input = input as f64 / cnt as f64;
216 let est = (count as f64 * avg_input * rate).max(0.0) as usize;
217 has_measured_data = true;
218 potential_tokens += est;
219 (format!("{:.0}%", (rate * 100.0).max(0.0)), est, true)
220 }
221 _ => ("—".to_string(), 0, false),
222 };
223 MissedCommand {
224 prefix: base,
225 description,
226 savings_range,
227 count,
228 estimated_tokens,
229 measured: is_measured,
230 }
231 })
232 .collect();
233
234 let potential_usd = potential_tokens as f64 * price_per_tok;
235
236 DiscoverResult {
237 total_commands,
238 already_optimized,
239 missed_commands,
240 potential_tokens,
241 potential_usd,
242 has_measured_data,
243 }
244}
245
246pub fn discover_from_history(history: &[String], limit: usize) -> String {
247 let result = analyze_history(history, limit);
248
249 if result.missed_commands.is_empty() {
250 return format!(
251 "No missed savings found in last {} commands. \
252 {} already optimized.",
253 result.total_commands, result.already_optimized
254 );
255 }
256
257 let mut lines = Vec::new();
258 lines.push(format!(
259 "Analyzed {} commands ({} already optimized):",
260 result.total_commands, result.already_optimized
261 ));
262 lines.push(String::new());
263
264 let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
265 lines.push(format!(
266 "{total_missed} commands could benefit from lean-ctx:"
267 ));
268 lines.push(String::new());
269
270 for m in &result.missed_commands {
271 lines.push(format!(
272 " {:>4}x {:<12} {} ({})",
273 m.count, m.prefix, m.description, m.savings_range
274 ));
275 }
276
277 lines.push(String::new());
278 if result.has_measured_data {
279 lines.push(format!(
280 "Estimated potential: ~{} tokens saved (~${:.2}), projected from your measured savings",
281 result.potential_tokens, result.potential_usd
282 ));
283 } else {
284 lines.push(
285 "No measured savings yet — run these via lean-ctx, then re-run discover for real numbers."
286 .to_string(),
287 );
288 }
289 lines.push(String::new());
290 lines.push("Fix: run 'lean-ctx init --global' to auto-compress all commands.".to_string());
291 lines.push("Or: run 'lean-ctx init --agent <tool>' for AI tool hooks.".to_string());
292
293 let output = lines.join("\n");
294 let tokens = count_tokens(&output);
295 format!("{output}\n\n[{tokens} tok]")
296}
297
298pub fn format_cli_output(result: &DiscoverResult) -> String {
299 if result.missed_commands.is_empty() {
300 return format!(
301 "All compressible commands are already using lean-ctx!\n\
302 ({} commands analyzed, {} via lean-ctx)",
303 result.total_commands, result.already_optimized
304 );
305 }
306
307 let mut lines = Vec::new();
308 let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
309
310 lines.push(format!(
311 "Found {total_missed} compressible commands not using lean-ctx:\n"
312 ));
313 lines.push(format!(
314 " {:<14} {:>5} {:>10} {:<30} {}",
315 "COMMAND", "COUNT", "SAVINGS", "DESCRIPTION", "EST. TOKENS"
316 ));
317 lines.push(format!(" {}", "-".repeat(80)));
318
319 for m in &result.missed_commands {
320 let est = if m.measured {
321 format!("~{}", m.estimated_tokens)
322 } else {
323 "—".to_string()
324 };
325 lines.push(format!(
326 " {:<14} {:>5}x {:>10} {:<30} {}",
327 m.prefix, m.count, m.savings_range, m.description, est
328 ));
329 }
330
331 lines.push(String::new());
332 if result.has_measured_data {
333 lines.push(format!(
334 "Estimated missed savings: ~{} tokens (~${:.2}/month), projected from your measured rate",
335 result.potential_tokens,
336 result.potential_usd * 30.0
337 ));
338 } else {
339 lines.push(
340 "No measured savings yet — route these through lean-ctx, then re-run discover."
341 .to_string(),
342 );
343 }
344 lines.push(format!(
345 "Already using lean-ctx: {} commands",
346 result.already_optimized
347 ));
348 lines.push(String::new());
349 lines.push("Run 'lean-ctx init --global' to enable compression for all commands.".to_string());
350
351 lines.join("\n")
352}
353
354pub fn render_before_card(result: &DiscoverResult) -> String {
360 let saved = crate::core::wrapped::format_tokens(result.potential_tokens as u64);
361 let monthly_usd = result.potential_usd * 30.0;
362 let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
363 let top = before_card_top_commands(result);
364 format!(
365 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">
366 <defs>
367 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
368 <stop offset="0" stop-color="#0b1020"/>
369 <stop offset="1" stop-color="#131a2e"/>
370 </linearGradient>
371 <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
372 <stop offset="0" stop-color="#f59e0b"/>
373 <stop offset="1" stop-color="#ef4444"/>
374 </linearGradient>
375 </defs>
376 <rect width="1200" height="630" fill="url(#bg)"/>
377 <rect x="0" y="0" width="1200" height="8" fill="url(#accent)"/>
378 <text x="70" y="92" fill="#e5e7eb" font-size="34" font-weight="700">lean-ctx <tspan fill="#f59e0b">Ghost Tokens</tspan></text>
379 <text x="70" y="130" fill="#94a3b8" font-size="24">before lean-ctx — estimated from my shell history</text>
380 <text x="70" y="300" fill="#f59e0b" font-size="120" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{saved}</text>
381 <text x="76" y="346" fill="#94a3b8" font-size="26">tokens/month left on the table</text>
382 <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>
383 <text x="74" y="462" fill="#94a3b8" font-size="22">potential monthly savings</text>
384 <text x="70" y="512" fill="#cbd5e1" font-size="22">{total_missed} uncompressed commands · {already} already via lean-ctx</text>
385{top}
386 <text x="70" y="600" fill="#475569" font-size="17">Estimate from local shell history · run `lean-ctx onboard` to stop the leak</text>
387 <text x="1130" y="600" text-anchor="end" fill="#f59e0b" font-size="26" font-weight="700">leanctx.com</text>
388</svg>"##,
389 already = result.already_optimized,
390 )
391}
392
393fn before_card_top_commands(result: &DiscoverResult) -> String {
395 if result.missed_commands.is_empty() {
396 return String::new();
397 }
398 let joined = result
399 .missed_commands
400 .iter()
401 .take(3)
402 .map(|m| format!("{} {}x", m.prefix, m.count))
403 .collect::<Vec<_>>()
404 .join(" · ");
405 format!(
406 " <text x=\"70\" y=\"556\" fill=\"#cbd5e1\" font-size=\"22\">top missed {}</text>",
407 xml_escape(&joined)
408 )
409}
410
411fn xml_escape(s: &str) -> String {
413 s.replace('&', "&")
414 .replace('<', "<")
415 .replace('>', ">")
416 .replace('"', """)
417 .replace('\'', "'")
418}
419
420#[cfg(test)]
421mod tests {
422 use super::{analyze_history, analyze_history_with_stats, render_before_card};
423 use crate::core::stats::{CommandStats, StatsStore};
424
425 fn history() -> Vec<String> {
426 vec![
427 "git status".into(),
428 "git diff".into(),
429 "cargo build".into(),
430 "cargo test".into(),
431 "lean-ctx gain".into(),
432 "vim notes.txt".into(),
433 ]
434 }
435
436 fn stats_with(entries: &[(&str, u64, u64, u64)]) -> StatsStore {
437 let mut s = StatsStore::default();
438 for (key, count, input, output) in entries {
439 s.commands.insert(
440 (*key).to_string(),
441 CommandStats {
442 count: *count,
443 input_tokens: *input,
444 output_tokens: *output,
445 },
446 );
447 s.total_commands += count;
448 s.total_input_tokens += input;
449 s.total_output_tokens += output;
450 }
451 s
452 }
453
454 #[test]
455 fn no_measured_data_yields_zero_potential_and_no_fabrication() {
456 let r = analyze_history_with_stats(&history(), 20, &StatsStore::default());
458 assert!(!r.has_measured_data, "no stats => no measured data");
459 assert_eq!(r.potential_tokens, 0, "must not fabricate tokens");
460 assert_eq!(r.potential_usd, 0.0, "must not fabricate dollars");
461 assert!(
462 r.missed_commands
463 .iter()
464 .all(|m| !m.measured && m.savings_range == "—"),
465 "unmeasured families show em dash, not a fake range"
466 );
467 assert_eq!(r.already_optimized, 1);
469 assert!(
470 r.missed_commands
471 .iter()
472 .any(|m| m.prefix == "git" && m.count == 2)
473 );
474 }
475
476 #[test]
477 fn measured_family_projects_real_savings() {
478 let store = stats_with(&[("git status", 2, 1000, 100)]);
480 let r = analyze_history_with_stats(&history(), 20, &store);
481 assert!(r.has_measured_data);
482 let git = r
483 .missed_commands
484 .iter()
485 .find(|m| m.prefix == "git")
486 .expect("git present");
487 assert!(git.measured);
488 assert_eq!(git.savings_range, "90%", "real measured rate, not a guess");
489 assert_eq!(git.estimated_tokens, 900);
491 assert!(git.savings_range.ends_with('%'));
492 let cargo = r
494 .missed_commands
495 .iter()
496 .find(|m| m.prefix == "cargo")
497 .unwrap();
498 assert_eq!(cargo.estimated_tokens, 0);
499 assert_eq!(r.potential_tokens, 900);
500 }
501
502 #[test]
503 fn newly_shipped_families_are_recognised() {
504 let hist: Vec<String> = ["dbt run", "trivy image nginx", "pulumi up", "jj log"]
506 .into_iter()
507 .map(String::from)
508 .collect();
509 let r = analyze_history_with_stats(&hist, 20, &StatsStore::default());
510 for fam in ["dbt", "trivy", "pulumi", "jj"] {
511 assert!(
512 r.missed_commands.iter().any(|m| m.prefix == fam),
513 "{fam} should be recognised as compressible"
514 );
515 }
516 }
517
518 #[test]
519 fn before_card_is_well_formed_and_branded() {
520 let result = analyze_history(&history(), 20);
521 let svg = render_before_card(&result);
522 assert!(svg.starts_with("<svg"), "must be an SVG document");
523 assert!(svg.trim_end().ends_with("</svg>"), "must close the svg tag");
524 assert!(svg.contains("leanctx.com"), "must carry the brand footer");
525 assert!(svg.contains("Ghost Tokens"), "must frame the leak");
526 assert!(
527 svg.contains("tokens/month left on the table"),
528 "headline label present"
529 );
530 }
531
532 #[test]
533 fn xml_escape_neutralizes_markup() {
534 assert_eq!(super::xml_escape("a<b>&\"'"), "a<b>&"'");
535 }
536}