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                "project_root" => {
84                    let path = std::path::Path::new(val.as_str());
85                    if !path.exists() || !path.is_dir() {
86                        eprintln!("Error: '{val}' is not an existing directory.");
87                        std::process::exit(1);
88                    }
89                    cfg.project_root = Some(val.clone());
90                }
91                "proxy.anthropic_upstream" => {
92                    cfg.proxy.anthropic_upstream = normalize_optional_upstream(val);
93                }
94                "proxy.openai_upstream" => {
95                    cfg.proxy.openai_upstream = normalize_optional_upstream(val);
96                }
97                "proxy.gemini_upstream" => {
98                    cfg.proxy.gemini_upstream = normalize_optional_upstream(val);
99                }
100                _ => {
101                    eprintln!("Unknown config key: {key}");
102                    std::process::exit(1);
103                }
104            }
105            match cfg.save() {
106                Ok(()) => println!("Updated {key} = {val}"),
107                Err(e) => eprintln!("Error saving config: {e}"),
108            }
109        }
110        "schema" => {
111            let schema = config::schema::ConfigSchema::generate();
112            println!(
113                "{}",
114                serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
115            );
116        }
117        "validate" => {
118            cmd_validate();
119        }
120        "apply" | "reload" => {
121            cmd_apply();
122        }
123        _ => {
124            eprintln!("Usage: lean-ctx config [init|set|schema|validate|apply]");
125            std::process::exit(1);
126        }
127    }
128}
129
130fn cmd_apply() {
131    use crate::daemon;
132    use crate::ipc;
133
134    println!("Applying config changes…");
135
136    // 1. Validate config first
137    println!("\n[1/4] Validating config…");
138    let schema = config::schema::ConfigSchema::generate();
139    let known = schema.known_keys();
140    let cfg = config::Config::load();
141
142    if let Some(path) = config::Config::path() {
143        if path.exists() {
144            if let Ok(raw) = std::fs::read_to_string(&path) {
145                if let Ok(table) = raw.parse::<toml::Table>() {
146                    let mut user_keys = Vec::new();
147                    fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
148                        for (k, v) in table {
149                            let full = if prefix.is_empty() {
150                                k.clone()
151                            } else {
152                                format!("{prefix}.{k}")
153                            };
154                            if let toml::Value::Table(sub) = v {
155                                collect_flat(sub, &full, out);
156                            } else {
157                                out.push(full);
158                            }
159                        }
160                    }
161                    collect_flat(&table, "", &mut user_keys);
162                    let warnings: Vec<_> = user_keys
163                        .iter()
164                        .filter(|uk| {
165                            !known.contains(uk)
166                                && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
167                        })
168                        .collect();
169                    if warnings.is_empty() {
170                        println!("  ✓ All config keys valid.");
171                    } else {
172                        for w in &warnings {
173                            eprintln!("  [WARN] Unknown key: {w}");
174                        }
175                        eprintln!(
176                            "  {} unknown key(s) found. Continuing anyway…",
177                            warnings.len()
178                        );
179                    }
180                }
181            }
182        }
183    }
184
185    // 2. Restart processes
186    println!("\n[2/4] Restarting processes…");
187    crate::proxy_autostart::stop();
188
189    if let Err(e) = daemon::stop_daemon() {
190        eprintln!("  Warning: daemon stop: {e}");
191    }
192
193    let orphans = ipc::process::kill_all_by_name("lean-ctx");
194    if orphans > 0 {
195        println!("  Terminated {orphans} orphan process(es).");
196    }
197
198    std::thread::sleep(std::time::Duration::from_millis(500));
199
200    let remaining = ipc::process::find_pids_by_name("lean-ctx");
201    if !remaining.is_empty() {
202        for &pid in &remaining {
203            let _ = ipc::process::force_kill(pid);
204        }
205        std::thread::sleep(std::time::Duration::from_millis(300));
206    }
207
208    daemon::cleanup_daemon_files();
209    crate::proxy_autostart::start();
210
211    match daemon::start_daemon(&[]) {
212        Ok(()) => println!("  ✓ Daemon restarted."),
213        Err(e) => {
214            eprintln!("  ✗ Daemon start failed: {e}");
215            std::process::exit(1);
216        }
217    }
218
219    // 3. Safety checks
220    println!("\n[3/4] Running safety checks…");
221    println!("  RAM guard: max {}% system", cfg.max_ram_percent);
222
223    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
224        let sessions_dir = data_dir.join("sessions");
225        let session_count = std::fs::read_dir(&sessions_dir)
226            .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
227        println!("  Sessions dir: {session_count} files");
228    }
229
230    // 4. Summary
231    println!("\n[4/4] Config applied successfully.");
232    println!("  Theme:       {}", cfg.theme);
233    println!("  Ultra compact: {}", cfg.ultra_compact);
234    println!("  Checkpoint:  every {} calls", cfg.checkpoint_interval);
235    if let Some(ref root) = cfg.project_root {
236        println!("  Project root: {root}");
237    }
238}
239
240fn cmd_validate() {
241    let schema = config::schema::ConfigSchema::generate();
242    let known = schema.known_keys();
243
244    let path = match config::Config::path() {
245        Some(p) if p.exists() => p,
246        _ => {
247            println!("[OK] No config.toml found — using defaults.");
248            return;
249        }
250    };
251
252    let raw = match std::fs::read_to_string(&path) {
253        Ok(s) => s,
254        Err(e) => {
255            eprintln!("[ERROR] Cannot read {}: {e}", path.display());
256            std::process::exit(1);
257        }
258    };
259
260    let table: toml::Table = match raw.parse() {
261        Ok(t) => t,
262        Err(e) => {
263            eprintln!("[ERROR] Invalid TOML: {e}");
264            std::process::exit(1);
265        }
266    };
267
268    let mut warnings = 0u32;
269    let mut validated = 0u32;
270
271    fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
272        for (k, v) in table {
273            let full = if prefix.is_empty() {
274                k.clone()
275            } else {
276                format!("{prefix}.{k}")
277            };
278            match v {
279                toml::Value::Table(sub) => collect_keys(sub, &full, out),
280                toml::Value::Array(arr) => {
281                    out.push(full.clone());
282                    for item in arr {
283                        if let toml::Value::Table(sub) = item {
284                            for sk in sub.keys() {
285                                out.push(format!("{full}[].{sk}"));
286                            }
287                        }
288                    }
289                }
290                _ => out.push(full),
291            }
292        }
293    }
294
295    let mut user_keys = Vec::new();
296    collect_keys(&table, "", &mut user_keys);
297
298    for uk in &user_keys {
299        let base = uk.split("[].").next().unwrap_or(uk);
300        let field = uk.rsplit("[].").next().unwrap_or("");
301        let check_key = if uk.contains("[].") {
302            format!("{base}.{field}")
303        } else {
304            uk.clone()
305        };
306
307        if known.contains(&check_key)
308            || known
309                .iter()
310                .any(|k| check_key.starts_with(&format!("{k}.")))
311        {
312            validated += 1;
313        } else {
314            warnings += 1;
315            let suggestion = find_closest(&check_key, &known);
316            if let Some(sug) = suggestion {
317                eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
318            } else {
319                eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
320            }
321        }
322    }
323
324    let total = validated + warnings;
325    if warnings == 0 {
326        println!(
327            "[OK] All {total} keys validated successfully ({}).",
328            path.display()
329        );
330    } else {
331        println!(
332            "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
333            path.display()
334        );
335        std::process::exit(1);
336    }
337}
338
339fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
340    let mut best: Option<(usize, &str)> = None;
341    for candidate in haystack {
342        let d = levenshtein(needle, candidate);
343        if d <= 3 && (best.is_none() || d < best.unwrap().0) {
344            best = Some((d, candidate));
345        }
346    }
347    if best.is_some() {
348        return best.map(|(_, s)| s.to_string());
349    }
350    let leaf = needle.rsplit('.').next().unwrap_or(needle);
351    let mut leaf_best: Option<(usize, &str)> = None;
352    for candidate in haystack {
353        let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
354        let d = levenshtein(leaf, cand_leaf);
355        if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
356            leaf_best = Some((d, candidate));
357        }
358    }
359    leaf_best.map(|(_, s)| s.to_string())
360}
361
362fn levenshtein(a: &str, b: &str) -> usize {
363    let a: Vec<char> = a.chars().collect();
364    let b: Vec<char> = b.chars().collect();
365    let (m, n) = (a.len(), b.len());
366    let mut dp = vec![vec![0usize; n + 1]; m + 1];
367    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
368        row[0] = i;
369    }
370    for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
371        *val = j;
372    }
373    for i in 1..=m {
374        for j in 1..=n {
375            let cost = usize::from(a[i - 1] != b[j - 1]);
376            dp[i][j] = (dp[i - 1][j] + 1)
377                .min(dp[i][j - 1] + 1)
378                .min(dp[i - 1][j - 1] + cost);
379        }
380    }
381    dp[m][n]
382}
383
384fn normalize_optional_upstream(value: &str) -> Option<String> {
385    use crate::core::config::normalize_url_opt;
386    let trimmed = value.trim();
387    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
388        None
389    } else {
390        normalize_url_opt(trimmed)
391    }
392}
393
394pub fn cmd_benchmark(args: &[String]) {
395    use crate::core::benchmark;
396
397    let action = args.first().map_or("run", std::string::String::as_str);
398
399    match action {
400        "--help" | "-h" => {
401            println!("Usage: lean-ctx benchmark run [path] [--json]");
402            println!("       lean-ctx benchmark report [path]");
403        }
404        "run" => {
405            let path = args.get(1).map_or(".", std::string::String::as_str);
406            let is_json = args.iter().any(|a| a == "--json");
407
408            let result = benchmark::run_project_benchmark(path);
409            if is_json {
410                println!("{}", benchmark::format_json(&result));
411            } else {
412                println!("{}", benchmark::format_terminal(&result));
413            }
414        }
415        "report" => {
416            let path = args.get(1).map_or(".", std::string::String::as_str);
417            let result = benchmark::run_project_benchmark(path);
418            println!("{}", benchmark::format_markdown(&result));
419        }
420        _ => {
421            if std::path::Path::new(action).exists() {
422                let result = benchmark::run_project_benchmark(action);
423                println!("{}", benchmark::format_terminal(&result));
424            } else {
425                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
426                eprintln!("       lean-ctx benchmark report [path]");
427                std::process::exit(1);
428            }
429        }
430    }
431}
432
433pub fn cmd_stats(args: &[String]) {
434    match args.first().map(std::string::String::as_str) {
435        Some("reset-cep") => {
436            crate::core::stats::reset_cep();
437            println!("CEP stats reset. Shell hook data preserved.");
438        }
439        Some("json") => {
440            let store = crate::core::stats::load();
441            println!(
442                "{}",
443                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
444            );
445        }
446        _ => {
447            let store = crate::core::stats::load();
448            let input_saved = store
449                .total_input_tokens
450                .saturating_sub(store.total_output_tokens);
451            let pct = if store.total_input_tokens > 0 {
452                input_saved as f64 / store.total_input_tokens as f64 * 100.0
453            } else {
454                0.0
455            };
456            println!("Commands:    {}", store.total_commands);
457            println!("Input:       {} tokens", store.total_input_tokens);
458            println!("Output:      {} tokens", store.total_output_tokens);
459            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
460            println!();
461            println!("CEP sessions:  {}", store.cep.sessions);
462            println!(
463                "CEP tokens:    {} → {}",
464                store.cep.total_tokens_original, store.cep.total_tokens_compressed
465            );
466            println!();
467            println!("Subcommands: stats reset-cep | stats json");
468        }
469    }
470}
471
472pub fn cmd_cache(args: &[String]) {
473    use crate::core::cli_cache;
474    match args.first().map(std::string::String::as_str) {
475        Some("clear") => {
476            let count = cli_cache::clear();
477            println!("Cleared {count} cached entries.");
478        }
479        Some("reset") => {
480            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
481            if project_flag {
482                let root =
483                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
484                if let Some(root) = root {
485                    let count = cli_cache::clear_project(&root);
486                    println!("Reset {count} cache entries for project: {root}");
487                } else {
488                    eprintln!("No active project root found. Start a session first.");
489                    std::process::exit(1);
490                }
491            } else {
492                let count = cli_cache::clear();
493                println!("Reset all {count} cache entries.");
494            }
495        }
496        Some("stats") => {
497            let (hits, reads, entries) = cli_cache::stats();
498            let rate = if reads > 0 {
499                (hits as f64 / reads as f64 * 100.0).round() as u32
500            } else {
501                0
502            };
503            println!("CLI Cache Stats:");
504            println!("  Entries:   {entries}");
505            println!("  Reads:     {reads}");
506            println!("  Hits:      {hits}");
507            println!("  Hit Rate:  {rate}%");
508        }
509        Some("invalidate") => {
510            if args.len() < 2 {
511                eprintln!("Usage: lean-ctx cache invalidate <path>");
512                std::process::exit(1);
513            }
514            cli_cache::invalidate(&args[1]);
515            println!("Invalidated cache for {}", args[1]);
516        }
517        Some("prune") => {
518            let result = prune_bm25_caches();
519            println!(
520                "Pruned {} entries, freed {:.1} MB",
521                result.removed,
522                result.bytes_freed as f64 / 1_048_576.0
523            );
524        }
525        _ => {
526            let (hits, reads, entries) = cli_cache::stats();
527            let rate = if reads > 0 {
528                (hits as f64 / reads as f64 * 100.0).round() as u32
529            } else {
530                0
531            };
532            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
533            println!();
534            println!("Subcommands:");
535            println!("  cache stats       Show detailed stats");
536            println!("  cache clear       Clear all cached entries");
537            println!("  cache reset       Reset all cache (or --project for current project only)");
538            println!("  cache invalidate  Remove specific file from cache");
539            println!(
540                "  cache prune       Remove oversized, quarantined, and orphaned BM25 indexes"
541            );
542        }
543    }
544}
545
546pub struct PruneResult {
547    pub scanned: u32,
548    pub removed: u32,
549    pub bytes_freed: u64,
550}
551
552pub fn prune_bm25_caches() -> PruneResult {
553    let mut result = PruneResult {
554        scanned: 0,
555        removed: 0,
556        bytes_freed: 0,
557    };
558
559    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
560        return result;
561    };
562    let vectors_dir = data_dir.join("vectors");
563    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
564        return result;
565    };
566
567    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
568
569    for entry in entries.flatten() {
570        let dir = entry.path();
571        if !dir.is_dir() {
572            continue;
573        }
574        result.scanned += 1;
575
576        for q_name in &[
577            "bm25_index.json.quarantined",
578            "bm25_index.bin.quarantined",
579            "bm25_index.bin.zst.quarantined",
580        ] {
581            let quarantined = dir.join(q_name);
582            if quarantined.exists() {
583                if let Ok(meta) = std::fs::metadata(&quarantined) {
584                    result.bytes_freed += meta.len();
585                }
586                let _ = std::fs::remove_file(&quarantined);
587                result.removed += 1;
588                println!("  Removed quarantined: {}", quarantined.display());
589            }
590        }
591
592        let index_path = if dir.join("bm25_index.bin.zst").exists() {
593            dir.join("bm25_index.bin.zst")
594        } else if dir.join("bm25_index.bin").exists() {
595            dir.join("bm25_index.bin")
596        } else {
597            dir.join("bm25_index.json")
598        };
599        if let Ok(meta) = std::fs::metadata(&index_path) {
600            if meta.len() > max_bytes {
601                result.bytes_freed += meta.len();
602                let _ = std::fs::remove_file(&index_path);
603                result.removed += 1;
604                println!(
605                    "  Removed oversized ({:.1} MB): {}",
606                    meta.len() as f64 / 1_048_576.0,
607                    index_path.display()
608                );
609            }
610        }
611
612        let marker = dir.join("project_root.txt");
613        if let Ok(root_str) = std::fs::read_to_string(&marker) {
614            let root_path = std::path::Path::new(root_str.trim());
615            if !root_path.exists() {
616                let freed = dir_size(&dir);
617                result.bytes_freed += freed;
618                let _ = std::fs::remove_dir_all(&dir);
619                result.removed += 1;
620                println!(
621                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
622                    freed as f64 / 1_048_576.0,
623                    root_str.trim(),
624                    dir.display()
625                );
626            }
627        }
628    }
629
630    result
631}
632
633fn dir_size(path: &std::path::Path) -> u64 {
634    let mut total = 0u64;
635    if let Ok(entries) = std::fs::read_dir(path) {
636        for entry in entries.flatten() {
637            let p = entry.path();
638            if p.is_file() {
639                total += std::fs::metadata(&p).map_or(0, |m| m.len());
640            } else if p.is_dir() {
641                total += dir_size(&p);
642            }
643        }
644    }
645    total
646}