Skip to main content

lean_ctx/cli/
config_cmd.rs

1use crate::core::config;
2use crate::core::theme;
3
4pub fn cmd_config(args: &[String]) {
5    let cfg = config::Config::load();
6
7    if args.is_empty() {
8        println!("{}", cfg.show());
9        return;
10    }
11
12    match args[0].as_str() {
13        "init" | "create" => {
14            let default = config::Config::default();
15            match default.save() {
16                Ok(()) => {
17                    let path = config::Config::path().map_or_else(
18                        || "~/.lean-ctx/config.toml".to_string(),
19                        |p| p.to_string_lossy().to_string(),
20                    );
21                    println!("Created default config at {path}");
22                }
23                Err(e) => eprintln!("Error: {e}"),
24            }
25        }
26        "set" => {
27            if args.len() < 3 {
28                eprintln!("Usage: lean-ctx config set <key> <value>");
29                std::process::exit(1);
30            }
31            let mut cfg = cfg;
32            let key = &args[1];
33            let val = &args[2];
34            match key.as_str() {
35                "ultra_compact" => cfg.ultra_compact = val == "true",
36                "tee_on_error" | "tee_mode" => {
37                    cfg.tee_mode = match val.as_str() {
38                        "true" | "failures" => config::TeeMode::Failures,
39                        "always" => config::TeeMode::Always,
40                        "false" | "never" => config::TeeMode::Never,
41                        _ => {
42                            eprintln!("Valid tee_mode values: always, failures, never");
43                            std::process::exit(1);
44                        }
45                    };
46                }
47                "checkpoint_interval" => {
48                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
49                }
50                "theme" => {
51                    if theme::from_preset(val).is_some() || val == "custom" {
52                        cfg.theme.clone_from(val);
53                    } else {
54                        eprintln!(
55                            "Unknown theme '{val}'. Available: {}",
56                            theme::PRESET_NAMES.join(", ")
57                        );
58                        std::process::exit(1);
59                    }
60                }
61                "slow_command_threshold_ms" => {
62                    cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
63                }
64                "passthrough_urls" => {
65                    cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
66                }
67                "excluded_commands" => {
68                    cfg.excluded_commands = val
69                        .split(',')
70                        .map(|s| s.trim().to_string())
71                        .filter(|s| !s.is_empty())
72                        .collect();
73                }
74                "rules_scope" => match val.as_str() {
75                    "global" | "project" | "both" => {
76                        cfg.rules_scope = Some(val.clone());
77                    }
78                    _ => {
79                        eprintln!("Valid rules_scope values: global, project, both");
80                        std::process::exit(1);
81                    }
82                },
83                "proxy.anthropic_upstream" => {
84                    cfg.proxy.anthropic_upstream = normalize_optional_upstream(val);
85                }
86                "proxy.openai_upstream" => {
87                    cfg.proxy.openai_upstream = normalize_optional_upstream(val);
88                }
89                "proxy.gemini_upstream" => {
90                    cfg.proxy.gemini_upstream = normalize_optional_upstream(val);
91                }
92                _ => {
93                    eprintln!("Unknown config key: {key}");
94                    std::process::exit(1);
95                }
96            }
97            match cfg.save() {
98                Ok(()) => println!("Updated {key} = {val}"),
99                Err(e) => eprintln!("Error saving config: {e}"),
100            }
101        }
102        "schema" => {
103            let schema = config::schema::ConfigSchema::generate();
104            println!(
105                "{}",
106                serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
107            );
108        }
109        "validate" => {
110            cmd_validate();
111        }
112        _ => {
113            eprintln!("Usage: lean-ctx config [init|set|schema|validate]");
114            std::process::exit(1);
115        }
116    }
117}
118
119fn cmd_validate() {
120    let schema = config::schema::ConfigSchema::generate();
121    let known = schema.known_keys();
122
123    let path = match config::Config::path() {
124        Some(p) if p.exists() => p,
125        _ => {
126            println!("[OK] No config.toml found — using defaults.");
127            return;
128        }
129    };
130
131    let raw = match std::fs::read_to_string(&path) {
132        Ok(s) => s,
133        Err(e) => {
134            eprintln!("[ERROR] Cannot read {}: {e}", path.display());
135            std::process::exit(1);
136        }
137    };
138
139    let table: toml::Table = match raw.parse() {
140        Ok(t) => t,
141        Err(e) => {
142            eprintln!("[ERROR] Invalid TOML: {e}");
143            std::process::exit(1);
144        }
145    };
146
147    let mut warnings = 0u32;
148    let mut validated = 0u32;
149
150    fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
151        for (k, v) in table {
152            let full = if prefix.is_empty() {
153                k.clone()
154            } else {
155                format!("{prefix}.{k}")
156            };
157            match v {
158                toml::Value::Table(sub) => collect_keys(sub, &full, out),
159                toml::Value::Array(arr) => {
160                    out.push(full.clone());
161                    for item in arr {
162                        if let toml::Value::Table(sub) = item {
163                            for sk in sub.keys() {
164                                out.push(format!("{full}[].{sk}"));
165                            }
166                        }
167                    }
168                }
169                _ => out.push(full),
170            }
171        }
172    }
173
174    let mut user_keys = Vec::new();
175    collect_keys(&table, "", &mut user_keys);
176
177    for uk in &user_keys {
178        let base = uk.split("[].").next().unwrap_or(uk);
179        let field = uk.rsplit("[].").next().unwrap_or("");
180        let check_key = if uk.contains("[].") {
181            format!("{base}.{field}")
182        } else {
183            uk.clone()
184        };
185
186        if known.contains(&check_key)
187            || known
188                .iter()
189                .any(|k| check_key.starts_with(&format!("{k}.")))
190        {
191            validated += 1;
192        } else {
193            warnings += 1;
194            let suggestion = find_closest(&check_key, &known);
195            if let Some(sug) = suggestion {
196                eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
197            } else {
198                eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
199            }
200        }
201    }
202
203    let total = validated + warnings;
204    if warnings == 0 {
205        println!(
206            "[OK] All {total} keys validated successfully ({}).",
207            path.display()
208        );
209    } else {
210        println!(
211            "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
212            path.display()
213        );
214        std::process::exit(1);
215    }
216}
217
218fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
219    let mut best: Option<(usize, &str)> = None;
220    for candidate in haystack {
221        let d = levenshtein(needle, candidate);
222        if d <= 3 && (best.is_none() || d < best.unwrap().0) {
223            best = Some((d, candidate));
224        }
225    }
226    if best.is_some() {
227        return best.map(|(_, s)| s.to_string());
228    }
229    let leaf = needle.rsplit('.').next().unwrap_or(needle);
230    let mut leaf_best: Option<(usize, &str)> = None;
231    for candidate in haystack {
232        let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
233        let d = levenshtein(leaf, cand_leaf);
234        if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
235            leaf_best = Some((d, candidate));
236        }
237    }
238    leaf_best.map(|(_, s)| s.to_string())
239}
240
241fn levenshtein(a: &str, b: &str) -> usize {
242    let a: Vec<char> = a.chars().collect();
243    let b: Vec<char> = b.chars().collect();
244    let (m, n) = (a.len(), b.len());
245    let mut dp = vec![vec![0usize; n + 1]; m + 1];
246    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
247        row[0] = i;
248    }
249    for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
250        *val = j;
251    }
252    for i in 1..=m {
253        for j in 1..=n {
254            let cost = usize::from(a[i - 1] != b[j - 1]);
255            dp[i][j] = (dp[i - 1][j] + 1)
256                .min(dp[i][j - 1] + 1)
257                .min(dp[i - 1][j - 1] + cost);
258        }
259    }
260    dp[m][n]
261}
262
263fn normalize_optional_upstream(value: &str) -> Option<String> {
264    use crate::core::config::normalize_url_opt;
265    let trimmed = value.trim();
266    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
267        None
268    } else {
269        normalize_url_opt(trimmed)
270    }
271}
272
273pub fn cmd_benchmark(args: &[String]) {
274    use crate::core::benchmark;
275
276    let action = args.first().map_or("run", std::string::String::as_str);
277
278    match action {
279        "--help" | "-h" => {
280            println!("Usage: lean-ctx benchmark run [path] [--json]");
281            println!("       lean-ctx benchmark report [path]");
282        }
283        "run" => {
284            let path = args.get(1).map_or(".", std::string::String::as_str);
285            let is_json = args.iter().any(|a| a == "--json");
286
287            let result = benchmark::run_project_benchmark(path);
288            if is_json {
289                println!("{}", benchmark::format_json(&result));
290            } else {
291                println!("{}", benchmark::format_terminal(&result));
292            }
293        }
294        "report" => {
295            let path = args.get(1).map_or(".", std::string::String::as_str);
296            let result = benchmark::run_project_benchmark(path);
297            println!("{}", benchmark::format_markdown(&result));
298        }
299        _ => {
300            if std::path::Path::new(action).exists() {
301                let result = benchmark::run_project_benchmark(action);
302                println!("{}", benchmark::format_terminal(&result));
303            } else {
304                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
305                eprintln!("       lean-ctx benchmark report [path]");
306                std::process::exit(1);
307            }
308        }
309    }
310}
311
312pub fn cmd_stats(args: &[String]) {
313    match args.first().map(std::string::String::as_str) {
314        Some("reset-cep") => {
315            crate::core::stats::reset_cep();
316            println!("CEP stats reset. Shell hook data preserved.");
317        }
318        Some("json") => {
319            let store = crate::core::stats::load();
320            println!(
321                "{}",
322                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
323            );
324        }
325        _ => {
326            let store = crate::core::stats::load();
327            let input_saved = store
328                .total_input_tokens
329                .saturating_sub(store.total_output_tokens);
330            let pct = if store.total_input_tokens > 0 {
331                input_saved as f64 / store.total_input_tokens as f64 * 100.0
332            } else {
333                0.0
334            };
335            println!("Commands:    {}", store.total_commands);
336            println!("Input:       {} tokens", store.total_input_tokens);
337            println!("Output:      {} tokens", store.total_output_tokens);
338            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
339            println!();
340            println!("CEP sessions:  {}", store.cep.sessions);
341            println!(
342                "CEP tokens:    {} → {}",
343                store.cep.total_tokens_original, store.cep.total_tokens_compressed
344            );
345            println!();
346            println!("Subcommands: stats reset-cep | stats json");
347        }
348    }
349}
350
351pub fn cmd_cache(args: &[String]) {
352    use crate::core::cli_cache;
353    match args.first().map(std::string::String::as_str) {
354        Some("clear") => {
355            let count = cli_cache::clear();
356            println!("Cleared {count} cached entries.");
357        }
358        Some("reset") => {
359            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
360            if project_flag {
361                let root =
362                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
363                if let Some(root) = root {
364                    let count = cli_cache::clear_project(&root);
365                    println!("Reset {count} cache entries for project: {root}");
366                } else {
367                    eprintln!("No active project root found. Start a session first.");
368                    std::process::exit(1);
369                }
370            } else {
371                let count = cli_cache::clear();
372                println!("Reset all {count} cache entries.");
373            }
374        }
375        Some("stats") => {
376            let (hits, reads, entries) = cli_cache::stats();
377            let rate = if reads > 0 {
378                (hits as f64 / reads as f64 * 100.0).round() as u32
379            } else {
380                0
381            };
382            println!("CLI Cache Stats:");
383            println!("  Entries:   {entries}");
384            println!("  Reads:     {reads}");
385            println!("  Hits:      {hits}");
386            println!("  Hit Rate:  {rate}%");
387        }
388        Some("invalidate") => {
389            if args.len() < 2 {
390                eprintln!("Usage: lean-ctx cache invalidate <path>");
391                std::process::exit(1);
392            }
393            cli_cache::invalidate(&args[1]);
394            println!("Invalidated cache for {}", args[1]);
395        }
396        Some("prune") => {
397            let result = prune_bm25_caches();
398            println!(
399                "Pruned {} entries, freed {:.1} MB",
400                result.removed,
401                result.bytes_freed as f64 / 1_048_576.0
402            );
403        }
404        _ => {
405            let (hits, reads, entries) = cli_cache::stats();
406            let rate = if reads > 0 {
407                (hits as f64 / reads as f64 * 100.0).round() as u32
408            } else {
409                0
410            };
411            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
412            println!();
413            println!("Subcommands:");
414            println!("  cache stats       Show detailed stats");
415            println!("  cache clear       Clear all cached entries");
416            println!("  cache reset       Reset all cache (or --project for current project only)");
417            println!("  cache invalidate  Remove specific file from cache");
418            println!(
419                "  cache prune       Remove oversized, quarantined, and orphaned BM25 indexes"
420            );
421        }
422    }
423}
424
425pub struct PruneResult {
426    pub scanned: u32,
427    pub removed: u32,
428    pub bytes_freed: u64,
429}
430
431pub fn prune_bm25_caches() -> PruneResult {
432    let mut result = PruneResult {
433        scanned: 0,
434        removed: 0,
435        bytes_freed: 0,
436    };
437
438    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
439        return result;
440    };
441    let vectors_dir = data_dir.join("vectors");
442    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
443        return result;
444    };
445
446    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
447
448    for entry in entries.flatten() {
449        let dir = entry.path();
450        if !dir.is_dir() {
451            continue;
452        }
453        result.scanned += 1;
454
455        let quarantined = dir.join("bm25_index.json.quarantined");
456        if quarantined.exists() {
457            if let Ok(meta) = std::fs::metadata(&quarantined) {
458                result.bytes_freed += meta.len();
459            }
460            let _ = std::fs::remove_file(&quarantined);
461            result.removed += 1;
462            println!("  Removed quarantined: {}", quarantined.display());
463        }
464
465        let index_path = dir.join("bm25_index.json");
466        if let Ok(meta) = std::fs::metadata(&index_path) {
467            if meta.len() > max_bytes {
468                result.bytes_freed += meta.len();
469                let _ = std::fs::remove_file(&index_path);
470                result.removed += 1;
471                println!(
472                    "  Removed oversized ({:.1} MB): {}",
473                    meta.len() as f64 / 1_048_576.0,
474                    index_path.display()
475                );
476            }
477        }
478
479        let marker = dir.join("project_root.txt");
480        if let Ok(root_str) = std::fs::read_to_string(&marker) {
481            let root_path = std::path::Path::new(root_str.trim());
482            if !root_path.exists() {
483                let freed = dir_size(&dir);
484                result.bytes_freed += freed;
485                let _ = std::fs::remove_dir_all(&dir);
486                result.removed += 1;
487                println!(
488                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
489                    freed as f64 / 1_048_576.0,
490                    root_str.trim(),
491                    dir.display()
492                );
493            }
494        }
495    }
496
497    result
498}
499
500fn dir_size(path: &std::path::Path) -> u64 {
501    let mut total = 0u64;
502    if let Ok(entries) = std::fs::read_dir(path) {
503        for entry in entries.flatten() {
504            let p = entry.path();
505            if p.is_file() {
506                total += std::fs::metadata(&p).map_or(0, |m| m.len());
507            } else if p.is_dir() {
508                total += dir_size(&p);
509            }
510        }
511    }
512    total
513}