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