Skip to main content

lean_ctx/tools/
ctx_discover.rs

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(|a, b| b.1.cmp(&a.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}