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