Skip to main content

lean_ctx/tools/
ctx_discover.rs

1use std::collections::HashMap;
2
3use crate::core::stats::StatsStore;
4use crate::core::tokens::count_tokens;
5use crate::shell::output_policy::{OutputPolicy, classify};
6
7/// Command families with a dedicated lean-ctx compressor, used to *recognise*
8/// compressible commands in shell history (the savings numbers themselves come
9/// from real measured `core::stats`, never from this table). `base` is the
10/// `normalize_command` base name; keep roughly in sync with the dispatch in
11/// `core::patterns::try_specific_pattern`.
12const 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    /// True when at least one missed command had real measured savings backing
112    /// its token estimate. When false, `potential_tokens` is 0 and callers
113    /// should fall back to a frequency-based framing instead of a $ figure.
114    pub has_measured_data: bool,
115}
116
117pub struct MissedCommand {
118    pub prefix: String,
119    pub description: String,
120    /// Real measured savings for this family (e.g. "84%"), or "—" when the
121    /// user has not yet run this family through lean-ctx.
122    pub savings_range: String,
123    pub count: u32,
124    pub estimated_tokens: usize,
125    pub measured: bool,
126}
127
128/// First whitespace token's file name — the `normalize_command` base.
129fn 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
144/// Aggregated real measurements per command family from `core::stats`:
145/// base → (input_tokens, output_tokens, count).
146fn 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_for_display();
159    analyze_history_with_stats(history, limit, &store)
160}
161
162/// Core analysis with the measured-stats source injected (testable, pure aside
163/// from the policy classifier). `analyze_history` is the disk-backed wrapper.
164fn 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        // A command is a missed save only if lean-ctx has a compressor for it
189        // (known family or already measured) AND the policy engine would
190        // actually compress it (not a protected verbatim/passthrough command).
191        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            // Project the family's *real* measured savings onto its plain-shell
210            // frequency. Families without measurements contribute nothing — we
211            // never invent a token figure.
212            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
354/// Renders a shareable "before lean-ctx" SVG card from a discover analysis — the
355/// "ghost tokens you're leaving on the table" framing that drives the first-run share
356/// loop. Same 1200x630 social-card dimensions and visual language as the Wrapped card,
357/// but in an amber/red "leak" palette. Pure string building; all data-derived text is
358/// XML-escaped. Aggregate estimates only — never command contents or arguments.
359pub 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
393/// The top three missed commands as a single muted line. Empty when none.
394fn 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
411/// Minimal XML text escaping for data-derived strings in the SVG card.
412fn xml_escape(s: &str) -> String {
413    s.replace('&', "&amp;")
414        .replace('<', "&lt;")
415        .replace('>', "&gt;")
416        .replace('"', "&quot;")
417        .replace('\'', "&apos;")
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        // Empty stats: detection still works, but NO token figure is invented.
457        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        // git (2) + cargo (2) recognised; vim ignored; lean-ctx already-optimized.
468        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        // git measured at 90% savings (1000 in -> 100 out over 2 runs => 500 avg in).
479        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        // 2 plain-shell git cmds * 500 avg_input * 0.9 = 900 projected.
490        assert_eq!(git.estimated_tokens, 900);
491        assert!(git.savings_range.ends_with('%'));
492        // cargo has no measurement => contributes nothing.
493        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        // Regression guard: families added in #657-#661 must be discoverable.
505        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&lt;b&gt;&amp;&quot;&apos;");
535    }
536}