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        println!(
10            "\nTip: this is the full config. For the few knobs most people touch, run\n     `lean-ctx config show` (high-level summary), or change one with\n     `lean-ctx config set <key> <value>`."
11        );
12        return;
13    }
14
15    match args[0].as_str() {
16        "init" | "create" => {
17            let full = args.iter().any(|a| a == "--full");
18            if full {
19                init_full_config();
20            } else {
21                match write_simplified_config() {
22                    Ok(path) => println!("Created simplified config at {path}"),
23                    Err(e) => eprintln!("Error: {e}"),
24                }
25            }
26        }
27        "set" => {
28            if args.len() < 3 {
29                eprintln!("Usage: lean-ctx config set <key> <value>");
30                std::process::exit(1);
31            }
32            let key = &args[1];
33            let val = &args[2];
34
35            // Special validation hooks for keys that need custom logic beyond
36            // what the schema type system can express. These either hard-fail
37            // early, or normalize the value/key that is actually persisted —
38            // the resolved (write_key, write_val) then flows through the single
39            // governed write path below so the #852 review covers every route.
40            let (write_key, write_val): (String, String) = match key.as_str() {
41                "theme" if theme::from_preset(val).is_none() && val != "custom" => {
42                    eprintln!(
43                        "Unknown theme '{val}'. Available: {}",
44                        theme::PRESET_NAMES.join(", ")
45                    );
46                    std::process::exit(1);
47                }
48                "tee_on_error" | "tee_mode" => {
49                    let normalized = match val.as_str() {
50                        "true" => "failures",
51                        "false" => "never",
52                        other => other,
53                    };
54                    ("tee_mode".to_string(), normalized.to_string())
55                }
56                "project_root" => {
57                    let path = std::path::Path::new(val.as_str());
58                    if !path.exists() || !path.is_dir() {
59                        eprintln!("Error: '{val}' is not an existing directory.");
60                        std::process::exit(1);
61                    }
62                    (key.clone(), val.clone())
63                }
64                "embedding.model"
65                    if crate::core::embeddings::model_registry::EmbeddingModel::from_str_name(
66                        val,
67                    )
68                    .is_none() =>
69                {
70                    eprintln!(
71                        "Unknown embedding model '{val}'. Available: minilm (default), \
72                         nomic — or hf:org/repo[@revision] for any HuggingFace repo with an \
73                         ONNX export, e.g. hf:jinaai/jina-embeddings-v2-base-code for code \
74                         (see docs/guides/custom-embeddings.md)."
75                    );
76                    std::process::exit(1);
77                }
78                "proxy.anthropic_upstream" | "proxy.openai_upstream" | "proxy.gemini_upstream" => {
79                    let effective = normalize_optional_upstream(val).unwrap_or_default();
80                    (key.clone(), effective)
81                }
82                _ => (key.clone(), val.clone()),
83            };
84
85            write_config_key(&write_key, &write_val, key, val, args);
86        }
87        "schema" => {
88            let schema = config::schema::ConfigSchema::generate();
89            println!(
90                "{}",
91                serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
92            );
93        }
94        "validate" => {
95            cmd_validate();
96        }
97        "show" | "effective" => {
98            cmd_show_effective();
99        }
100        "apply" | "reload" => {
101            cmd_apply();
102        }
103        _ => {
104            eprintln!("Usage: lean-ctx config [init|set|show|schema|validate|apply]");
105            std::process::exit(1);
106        }
107    }
108}
109
110/// Single governed write path for `config set` (#852).
111///
112/// `key`/`value` are the resolved pair actually persisted; `display_key`/
113/// `display_val` are what the user typed (kept for messaging, e.g. when a value
114/// was normalized). Behavior:
115/// - **no-op** (current == new): report "unchanged", write nothing.
116/// - **consequential key** ([`config::risk`]): print a before→after review + a
117///   risk note, then require confirmation or `--yes`; abort if declined.
118/// - **routine key**: write directly, as before.
119fn write_config_key(key: &str, value: &str, display_key: &str, display_val: &str, args: &[String]) {
120    const BOLD: &str = "\x1b[1m";
121    const DIM: &str = "\x1b[2m";
122    const YELLOW: &str = "\x1b[33m";
123    const RST: &str = "\x1b[0m";
124
125    let current = config::setter::current_value(key);
126
127    if current.as_deref() == Some(value) {
128        println!("{display_key} is already set to {display_val} — unchanged.");
129        return;
130    }
131
132    if let Some(risk) = config::risk::classify(key) {
133        let before = current.as_deref().unwrap_or("(default)");
134        let after = if value.is_empty() { "(default)" } else { value };
135        println!("{BOLD}Review change to {display_key}{RST}");
136        println!("  {before}  →  {after}");
137        println!("  {YELLOW}{}{RST}", risk.note);
138        if !super::prompt::confirm(
139            &format!("Apply {display_key} = {display_val}?"),
140            super::prompt::wants_yes(args),
141        ) {
142            println!("{DIM}Aborted — {display_key} left unchanged.{RST}");
143            return;
144        }
145    }
146
147    match config::setter::set_by_key(key, value) {
148        Ok(_) => println!("Updated {display_key} = {display_val}"),
149        Err(e) => {
150            eprintln!("{e}");
151            std::process::exit(1);
152        }
153    }
154}
155
156/// Resolves the [`config::Config`] that `config init --full` should persist.
157///
158/// `existing_raw` is the verbatim content of the current GLOBAL `config.toml`
159/// (never the project-local `.lean-ctx.toml` — those overrides must not leak
160/// into the global file). When it holds a non-empty, parseable document we
161/// return its deserialized form so every user value is retained; an empty/absent
162/// file yields defaults, and an unparseable one is surfaced as an error so the
163/// caller can refuse to clobber it.
164///
165/// This is the regression guard for #443: `config init --full` previously wrote
166/// `Config::default()`, and `Config::save()` overwrites any key present in both
167/// the incoming document and the file (see `config_io::merge_table`), which
168/// silently reset customized values like `max_ram_percent` or `compression_level`.
169fn config_for_full_init(existing_raw: Option<&str>) -> Result<config::Config, String> {
170    match existing_raw.map(str::trim).filter(|raw| !raw.is_empty()) {
171        Some(raw) => toml::from_str::<config::Config>(raw).map_err(|e| e.to_string()),
172        None => Ok(config::Config::default()),
173    }
174}
175
176/// Implements `config init --full`: (re)writes the global config as a fully
177/// annotated reference document, seeded with the user's existing values (#443).
178///
179/// Unlike `save()` (which keeps the file minimal), this emits every key with its
180/// documentation. The body is a verbatim serialization of the resolved config,
181/// so no customized value is ever lost; an unparseable existing file is refused
182/// upstream by [`config_for_full_init`] rather than clobbered.
183fn init_full_config() {
184    let Some(path) = config::Config::path() else {
185        eprintln!("Error: cannot determine the config path");
186        return;
187    };
188
189    let existing_raw = std::fs::read_to_string(&path).ok();
190
191    let cfg = match config_for_full_init(existing_raw.as_deref()) {
192        Ok(cfg) => cfg,
193        Err(e) => {
194            eprintln!(
195                "Error: refusing to overwrite an unparseable config.toml ({e}).\n  \
196                 Fix it manually or run `lean-ctx doctor --fix`, then retry."
197            );
198            return;
199        }
200    };
201
202    let schema = config::schema::ConfigSchema::generate();
203    let rendered = config::render_annotated_config(&cfg, &schema);
204
205    match crate::config_io::write_atomic_with_backup(&path, &rendered) {
206        Ok(()) => println!("Created full annotated config at {}", path.display()),
207        Err(e) => eprintln!("Error: {e}"),
208    }
209}
210
211fn cmd_apply() {
212    use crate::daemon;
213    use crate::ipc;
214
215    println!("Applying config changes…");
216
217    // 1. Validate config first
218    println!("\n[1/4] Validating config…");
219    let schema = config::schema::ConfigSchema::generate();
220    let known = schema.known_keys();
221    let cfg = config::Config::load();
222
223    if let Some(path) = config::Config::path()
224        && path.exists()
225        && let Ok(raw) = std::fs::read_to_string(&path)
226        && let Ok(table) = raw.parse::<toml::Table>()
227    {
228        let mut user_keys = Vec::new();
229        fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
230            for (k, v) in table {
231                let full = if prefix.is_empty() {
232                    k.clone()
233                } else {
234                    format!("{prefix}.{k}")
235                };
236                if let toml::Value::Table(sub) = v {
237                    collect_flat(sub, &full, out);
238                } else {
239                    out.push(full);
240                }
241            }
242        }
243        collect_flat(&table, "", &mut user_keys);
244        let warnings: Vec<_> = user_keys
245            .iter()
246            .filter(|uk| {
247                !known.contains(uk) && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
248            })
249            .collect();
250        if warnings.is_empty() {
251            println!("  ✓ All config keys valid.");
252        } else {
253            for w in &warnings {
254                eprintln!("  [WARN] Unknown key: {w}");
255            }
256            eprintln!(
257                "  {} unknown key(s) found. Continuing anyway…",
258                warnings.len()
259            );
260        }
261    }
262
263    // 2. Restart processes
264    println!("\n[2/4] Restarting processes…");
265    crate::proxy_autostart::stop();
266
267    if let Err(e) = daemon::stop_daemon() {
268        eprintln!("  Warning: daemon stop: {e}");
269    }
270
271    let orphans = ipc::process::kill_all_by_name("lean-ctx");
272    if orphans > 0 {
273        println!("  Terminated {orphans} orphan process(es).");
274    }
275
276    std::thread::sleep(std::time::Duration::from_millis(500));
277
278    let remaining = ipc::process::find_pids_by_name("lean-ctx");
279    if !remaining.is_empty() {
280        for &pid in &remaining {
281            let _ = ipc::process::force_kill(pid);
282        }
283        std::thread::sleep(std::time::Duration::from_millis(300));
284    }
285
286    daemon::cleanup_daemon_files();
287    crate::proxy_autostart::start();
288
289    match daemon::start_daemon(&[]) {
290        Ok(()) => println!("  ✓ Daemon restarted."),
291        Err(e) => {
292            eprintln!("  ✗ Daemon start failed: {e}");
293            std::process::exit(1);
294        }
295    }
296
297    // 3. Safety checks
298    println!("\n[3/4] Running safety checks…");
299    println!("  RAM guard: max {}% system", cfg.max_ram_percent);
300
301    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
302        let sessions_dir = data_dir.join("sessions");
303        let session_count = std::fs::read_dir(&sessions_dir)
304            .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
305        println!("  Sessions dir: {session_count} files");
306    }
307
308    // 4. Summary
309    println!("\n[4/4] Config applied successfully.");
310    println!("  Theme:       {}", cfg.theme);
311    println!("  Ultra compact: {}", cfg.ultra_compact);
312    println!("  Checkpoint:  every {} calls", cfg.checkpoint_interval);
313    if let Some(ref root) = cfg.project_root {
314        println!("  Project root: {root}");
315    }
316}
317
318fn cmd_validate() {
319    // GH #450: always surface *where* the effective settings come from first, so
320    // a "no config" result is never a dead end and a silently shadowed value
321    // (env / project-local / parse error) is immediately visible.
322    print_config_provenance();
323
324    let schema = config::schema::ConfigSchema::generate();
325    let known = schema.known_keys();
326
327    let path = match config::Config::path() {
328        Some(p) if p.exists() => p,
329        Some(p) => {
330            println!("[OK] No config.toml at {} — using defaults.", p.display());
331            return;
332        }
333        None => {
334            println!("[OK] No config dir resolved — using defaults.");
335            return;
336        }
337    };
338
339    let raw = match std::fs::read_to_string(&path) {
340        Ok(s) => s,
341        Err(e) => {
342            eprintln!("[ERROR] Cannot read {}: {e}", path.display());
343            std::process::exit(1);
344        }
345    };
346
347    let table: toml::Table = match raw.parse() {
348        Ok(t) => t,
349        Err(e) => {
350            eprintln!("[ERROR] Invalid TOML: {e}");
351            std::process::exit(1);
352        }
353    };
354
355    let mut warnings = 0u32;
356    let mut validated = 0u32;
357
358    fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
359        for (k, v) in table {
360            let full = if prefix.is_empty() {
361                k.clone()
362            } else {
363                format!("{prefix}.{k}")
364            };
365            match v {
366                toml::Value::Table(sub) => collect_keys(sub, &full, out),
367                toml::Value::Array(arr) => {
368                    out.push(full.clone());
369                    for item in arr {
370                        if let toml::Value::Table(sub) = item {
371                            for sk in sub.keys() {
372                                out.push(format!("{full}[].{sk}"));
373                            }
374                        }
375                    }
376                }
377                _ => out.push(full),
378            }
379        }
380    }
381
382    let mut user_keys = Vec::new();
383    collect_keys(&table, "", &mut user_keys);
384
385    for uk in &user_keys {
386        let base = uk.split("[].").next().unwrap_or(uk);
387        let field = uk.rsplit("[].").next().unwrap_or("");
388        let check_key = if uk.contains("[].") {
389            format!("{base}.{field}")
390        } else {
391            uk.clone()
392        };
393
394        if known.contains(&check_key)
395            || known
396                .iter()
397                .any(|k| check_key.starts_with(&format!("{k}.")))
398        {
399            validated += 1;
400        } else {
401            warnings += 1;
402            let suggestion = find_closest(&check_key, &known);
403            if let Some(sug) = suggestion {
404                eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
405            } else {
406                eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
407            }
408        }
409    }
410
411    let cfg = config::Config::load();
412    let budget = cfg.max_disk_mb_effective();
413    if budget > 0 {
414        let explicit_archive = cfg.archive.max_disk_mb;
415        let explicit_bm25 = cfg.bm25_max_cache_mb;
416        let sum = explicit_archive + explicit_bm25;
417        if sum > budget {
418            warnings += 1;
419            println!(
420                "  ⚠ max_disk_mb={budget} but archive.max_disk_mb({explicit_archive}) + bm25_max_cache_mb({explicit_bm25}) = {sum} exceeds budget"
421            );
422        }
423    }
424
425    let total = validated + warnings;
426    if warnings == 0 {
427        println!(
428            "[OK] All {total} keys validated successfully ({}).",
429            path.display()
430        );
431    } else {
432        println!(
433            "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
434            path.display()
435        );
436        std::process::exit(1);
437    }
438}
439
440/// Print where the editable settings actually come from (GH #450): the resolved
441/// `config.toml` path, the layout pin, any parse error, and the env /
442/// project-local overrides that can silently shadow a saved value. This makes the
443/// "my quick settings keep resetting" reports self-diagnosing — the reporter sees
444/// the exact mechanism instead of an opaque "no config" message.
445fn print_config_provenance() {
446    let prov = config::Config::provenance();
447
448    println!("Config source:");
449    match &prov.config_path {
450        Some(p) if prov.config_exists => println!("  config.toml:    {} (exists)", p.display()),
451        Some(p) => println!(
452            "  config.toml:    {} (missing — using defaults)",
453            p.display()
454        ),
455        None => println!("  config.toml:    <no config dir resolved — using defaults>"),
456    }
457    println!(
458        "  layout pin:     {}",
459        if prov.xdg_pinned { "xdg" } else { "unpinned" }
460    );
461
462    if let Some(err) = &prov.parse_error {
463        println!("  [!] parse error: config.toml is unparseable — running on DEFAULTS:");
464        println!("                  {err}");
465        println!("                  Run `lean-ctx doctor --fix` to repair.");
466    }
467
468    if prov.local_exists && !prov.local_keys.is_empty() {
469        let path = prov
470            .local_path
471            .as_ref()
472            .map(|p| p.display().to_string())
473            .unwrap_or_default();
474        println!(
475            "  [!] project-local: {path} overrides {}",
476            prov.local_keys.join(", ")
477        );
478        println!("                  (these win over the global config for this project)");
479    }
480
481    if !prov.env_overrides.is_empty() {
482        let list = prov
483            .env_overrides
484            .iter()
485            .map(|e| format!("{} ({})", e.var, e.setting))
486            .collect::<Vec<_>>()
487            .join(", ");
488        println!("  [!] env override: {list}");
489        println!(
490            "                  (these win over config.toml; unset them for saved values to apply)"
491        );
492    }
493
494    if prov.has_shadow() {
495        println!(
496            "  -> A saved setting can appear to \"reset\" because a source above shadows it (GH #450)."
497        );
498    }
499    println!();
500}
501
502fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
503    let mut best: Option<(usize, &str)> = None;
504    for candidate in haystack {
505        let d = levenshtein(needle, candidate);
506        if d <= 3 && (best.is_none() || d < best.unwrap().0) {
507            best = Some((d, candidate));
508        }
509    }
510    if best.is_some() {
511        return best.map(|(_, s)| s.to_string());
512    }
513    let leaf = needle.rsplit('.').next().unwrap_or(needle);
514    let mut leaf_best: Option<(usize, &str)> = None;
515    for candidate in haystack {
516        let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
517        let d = levenshtein(leaf, cand_leaf);
518        if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
519            leaf_best = Some((d, candidate));
520        }
521    }
522    leaf_best.map(|(_, s)| s.to_string())
523}
524
525fn levenshtein(a: &str, b: &str) -> usize {
526    let a: Vec<char> = a.chars().collect();
527    let b: Vec<char> = b.chars().collect();
528    let (m, n) = (a.len(), b.len());
529    let mut dp = vec![vec![0usize; n + 1]; m + 1];
530    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
531        row[0] = i;
532    }
533    for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
534        *val = j;
535    }
536    for i in 1..=m {
537        for j in 1..=n {
538            let cost = usize::from(a[i - 1] != b[j - 1]);
539            dp[i][j] = (dp[i - 1][j] + 1)
540                .min(dp[i][j - 1] + 1)
541                .min(dp[i - 1][j - 1] + cost);
542        }
543    }
544    dp[m][n]
545}
546
547fn normalize_optional_upstream(value: &str) -> Option<String> {
548    use crate::core::config::normalize_url_opt;
549    let trimmed = value.trim();
550    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
551        None
552    } else {
553        normalize_url_opt(trimmed)
554    }
555}
556
557pub fn cmd_benchmark(args: &[String]) {
558    use crate::core::benchmark;
559    use crate::core::benchmark_compare;
560
561    let action = args.first().map_or("run", std::string::String::as_str);
562
563    match action {
564        "--help" | "-h" => {
565            println!("Usage: lean-ctx benchmark run [path] [--json]");
566            println!("       lean-ctx benchmark report [path]");
567            println!("       lean-ctx benchmark eval [path] [--json]");
568            println!("       lean-ctx benchmark eval-ab [path] [--suite file.ndjson] [--json]");
569            println!("       lean-ctx benchmark compare [--repo path] [--output file.md]");
570            println!("       lean-ctx benchmark scorecard [--json] [--output file]");
571            println!("       lean-ctx benchmark dual-arm [--json] [--output file]");
572        }
573        "dual-arm" => {
574            let is_json = args.iter().any(|a| a == "--json");
575            let output = parse_flag_value(args, "--output");
576            match crate::core::scorecard::dual_arm::run_dual_arm() {
577                Ok(sc) => {
578                    let rendered = if is_json { sc.to_json() } else { sc.to_human() };
579                    if let Some(path) = output {
580                        if let Err(e) = std::fs::write(&path, &rendered) {
581                            eprintln!("Failed to write dual-arm scorecard to {path}: {e}");
582                            std::process::exit(1);
583                        }
584                        eprintln!("Wrote dual-arm scorecard to {path}");
585                    } else {
586                        print!("{rendered}");
587                    }
588                }
589                Err(e) => {
590                    eprintln!("Dual-arm bench failed: {e}");
591                    std::process::exit(1);
592                }
593            }
594        }
595        "scorecard" => {
596            let is_json = args.iter().any(|a| a == "--json");
597            let output = parse_flag_value(args, "--output");
598            match crate::core::scorecard::run_scorecard() {
599                Ok(sc) => {
600                    let rendered = if is_json { sc.to_json() } else { sc.to_human() };
601                    if let Some(path) = output {
602                        if let Err(e) = std::fs::write(&path, &rendered) {
603                            eprintln!("Failed to write scorecard to {path}: {e}");
604                            std::process::exit(1);
605                        }
606                        eprintln!("Wrote scorecard to {path}");
607                    } else {
608                        print!("{rendered}");
609                    }
610                }
611                Err(e) => {
612                    eprintln!("Scorecard failed: {e}");
613                    std::process::exit(1);
614                }
615            }
616        }
617        "eval" => {
618            let path = args.get(1).map_or(".", std::string::String::as_str);
619            let is_json = args.iter().any(|a| a == "--json");
620            let root = std::path::Path::new(path);
621
622            let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
623            let cfg = crate::core::hybrid_search::HybridConfig::from_config();
624            let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
625
626            if queries.is_empty() {
627                eprintln!("No symbols found — cannot generate eval queries.");
628                std::process::exit(1);
629            }
630
631            let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
632            if is_json {
633                if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
634                    println!("{json}");
635                }
636            } else {
637                print!("{scorecard}");
638            }
639        }
640        "eval-ab" => {
641            let path = args
642                .get(1)
643                .filter(|a| !a.starts_with("--"))
644                .map_or(".", std::string::String::as_str);
645            let is_json = args.iter().any(|a| a == "--json");
646            let root = std::path::Path::new(path);
647            if !root.exists() {
648                eprintln!("Path does not exist: {path}");
649                std::process::exit(1);
650            }
651
652            let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
653            let cfg = crate::core::hybrid_search::HybridConfig::from_config();
654
655            let queries = match parse_flag_value(args, "--suite") {
656                Some(suite) => {
657                    match crate::core::eval_harness::load_suite(std::path::Path::new(&suite)) {
658                        Ok(q) => q,
659                        Err(e) => {
660                            eprintln!("Failed to load suite {suite}: {e}");
661                            std::process::exit(1);
662                        }
663                    }
664                }
665                None => crate::core::eval_harness::generate_self_eval(&index, 50),
666            };
667
668            if queries.is_empty() {
669                eprintln!("No eval queries (empty suite / no symbols indexed).");
670                std::process::exit(1);
671            }
672
673            let report = crate::core::eval_harness::run_ab(root, &queries, &index, &cfg);
674            if is_json {
675                println!("{}", report.to_json());
676            } else {
677                print!("{report}");
678            }
679        }
680        "run" => {
681            let path = args.get(1).map_or(".", std::string::String::as_str);
682            let is_json = args.iter().any(|a| a == "--json");
683
684            let result = benchmark::run_project_benchmark(path);
685            if is_json {
686                println!("{}", benchmark::format_json(&result));
687            } else {
688                println!("{}", benchmark::format_terminal(&result));
689            }
690        }
691        "report" => {
692            let path = args.get(1).map_or(".", std::string::String::as_str);
693            let result = benchmark::run_project_benchmark(path);
694            println!("{}", benchmark::format_markdown(&result));
695        }
696        "compare" => {
697            let repo = parse_flag_value(args, "--repo").unwrap_or_else(|| ".".to_string());
698            let output = parse_flag_value(args, "--output");
699
700            let root = std::path::Path::new(&repo);
701            if !root.exists() {
702                eprintln!("Repository path does not exist: {repo}");
703                std::process::exit(1);
704            }
705
706            let report = benchmark_compare::run_compare(root, output.as_deref());
707
708            println!("{}", benchmark_compare::report::generate_terminal(&report));
709
710            if output.is_none() {
711                eprintln!("Tip: use --output BENCHMARKS.md to save the full markdown report");
712            }
713        }
714        _ => {
715            if std::path::Path::new(action).exists() {
716                let result = benchmark::run_project_benchmark(action);
717                println!("{}", benchmark::format_terminal(&result));
718            } else {
719                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
720                eprintln!("       lean-ctx benchmark report [path]");
721                eprintln!("       lean-ctx benchmark eval [path] [--json]");
722                eprintln!(
723                    "       lean-ctx benchmark eval-ab [path] [--suite file.ndjson] [--json]"
724                );
725                eprintln!("       lean-ctx benchmark compare [--repo path] [--output file.md]");
726                eprintln!("       lean-ctx benchmark scorecard [--json] [--output file]");
727                std::process::exit(1);
728            }
729        }
730    }
731}
732
733fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
734    args.iter()
735        .position(|a| a == flag)
736        .and_then(|i| args.get(i + 1))
737        .cloned()
738}
739
740pub fn cmd_stats(args: &[String]) {
741    match args.first().map(std::string::String::as_str) {
742        Some("reset-cep") => {
743            crate::core::stats::reset_cep();
744            println!("CEP stats reset. Shell hook data preserved.");
745        }
746        Some("json") => {
747            let store = crate::core::stats::load();
748            println!(
749                "{}",
750                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
751            );
752        }
753        _ => {
754            let store = crate::core::stats::load();
755            let input_saved = store
756                .total_input_tokens
757                .saturating_sub(store.total_output_tokens);
758            let pct = if store.total_input_tokens > 0 {
759                input_saved as f64 / store.total_input_tokens as f64 * 100.0
760            } else {
761                0.0
762            };
763            println!("Commands:    {}", store.total_commands);
764            println!("Input:       {} tokens", store.total_input_tokens);
765            println!("Output:      {} tokens", store.total_output_tokens);
766            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
767            println!();
768            println!("CEP sessions:  {}", store.cep.sessions);
769            println!(
770                "CEP tokens:    {} → {}",
771                store.cep.total_tokens_original, store.cep.total_tokens_compressed
772            );
773            println!();
774            println!("Subcommands: stats reset-cep | stats json");
775        }
776    }
777}
778
779pub fn cmd_cache(args: &[String]) {
780    use crate::core::cli_cache;
781    match args.first().map(std::string::String::as_str) {
782        Some("clear") => {
783            let count = cli_cache::clear();
784            println!("Cleared {count} cached entries.");
785        }
786        Some("reset") => {
787            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
788            if project_flag {
789                let root =
790                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
791                if let Some(root) = root {
792                    let count = cli_cache::clear_project(&root);
793                    println!("Reset {count} cache entries for project: {root}");
794                } else {
795                    eprintln!("No active project root found. Start a session first.");
796                    std::process::exit(1);
797                }
798            } else {
799                let count = cli_cache::clear();
800                println!("Reset all {count} cache entries.");
801            }
802        }
803        Some("stats") => {
804            let (hits, reads, entries) = cli_cache::stats();
805            let rate = if reads > 0 {
806                (hits as f64 / reads as f64 * 100.0).round() as u32
807            } else {
808                0
809            };
810            println!("CLI Cache Stats (lean-ctx read / lean-ctx grep):");
811            println!("  Entries:   {entries}");
812            println!("  Reads:     {reads}");
813            println!("  Hits:      {hits}");
814            println!("  Hit Rate:  {rate}%");
815
816            if let Ok(dir) = crate::core::paths::state_dir() {
817                let live_path = dir.join("mcp-live.json");
818                if let Ok(content) = std::fs::read_to_string(&live_path) {
819                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
820                        let mcp_reads = val
821                            .get("total_reads")
822                            .and_then(serde_json::Value::as_u64)
823                            .unwrap_or(0);
824                        let mcp_hits = val
825                            .get("cache_hits")
826                            .and_then(serde_json::Value::as_u64)
827                            .unwrap_or(0);
828                        let mcp_saved = val
829                            .get("tokens_saved")
830                            .and_then(serde_json::Value::as_u64)
831                            .unwrap_or(0);
832                        let mcp_rate = if mcp_reads > 0 {
833                            (mcp_hits as f64 / mcp_reads as f64 * 100.0).round() as u32
834                        } else {
835                            0
836                        };
837                        let updated = val
838                            .get("updated_at")
839                            .and_then(serde_json::Value::as_str)
840                            .unwrap_or("unknown");
841                        println!();
842                        println!("MCP Session Cache (ctx_read via AI editor):");
843                        println!("  Reads:         {mcp_reads}");
844                        println!("  Hits:          {mcp_hits}");
845                        println!("  Hit Rate:      {mcp_rate}%");
846                        println!("  Tokens Saved:  {mcp_saved}");
847                        println!("  Last Updated:  {updated}");
848                    }
849                } else {
850                    println!();
851                    println!(
852                        "MCP Session Cache: no data yet (start a session with your AI editor)"
853                    );
854                }
855            }
856        }
857        Some("invalidate") => {
858            if args.len() < 2 {
859                eprintln!("Usage: lean-ctx cache invalidate <path>");
860                std::process::exit(1);
861            }
862            cli_cache::invalidate(&args[1]);
863            println!("Invalidated cache for {}", args[1]);
864        }
865        Some("prune") => {
866            let bm25 = prune_bm25_caches();
867            let graph = prune_graph_caches();
868            // Enforce the archive TTL + on-disk size budget alongside the index
869            // caches so a manual prune reclaims the (often largest) store too (#417).
870            let archive_before = crate::core::archive::disk_usage_bytes()
871                + crate::core::archive_fts::db_size_bytes();
872            let archive_removed = crate::core::archive::cleanup();
873            let _ = crate::core::archive_fts::enforce_cap();
874            let archive_after = crate::core::archive::disk_usage_bytes()
875                + crate::core::archive_fts::db_size_bytes();
876            let archive_freed = archive_before.saturating_sub(archive_after);
877
878            // Reclaim knowledge stores whose project_root was deleted (removed
879            // worktrees, thrown-away projects): they can never be written again,
880            // so their per-store eviction cap can never self-heal — pure bloat (#615).
881            let orphans = crate::core::knowledge::maintenance::prune_orphaned_stores();
882
883            let removed = bm25.removed + graph.removed + archive_removed + orphans.removed as u32;
884            let freed =
885                bm25.bytes_freed + graph.bytes_freed + archive_freed + orphans.reclaimed_bytes;
886            println!(
887                "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {}, archive: {}, orphaned stores: {})",
888                removed,
889                freed as f64 / 1_048_576.0,
890                bm25.removed,
891                graph.removed,
892                archive_removed,
893                orphans.removed,
894            );
895        }
896        _ => {
897            let (hits, reads, entries) = cli_cache::stats();
898            let rate = if reads > 0 {
899                (hits as f64 / reads as f64 * 100.0).round() as u32
900            } else {
901                0
902            };
903            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
904            println!();
905            println!("Subcommands:");
906            println!("  cache stats       Show detailed stats");
907            println!("  cache clear       Clear all cached entries");
908            println!("  cache reset       Reset all cache (or --project for current project only)");
909            println!("  cache invalidate  Remove specific file from cache");
910            println!(
911                "  cache prune       Reclaim BM25 + graph indexes, archive, and orphaned knowledge stores"
912            );
913        }
914    }
915}
916
917pub struct PruneResult {
918    pub scanned: u32,
919    pub removed: u32,
920    pub bytes_freed: u64,
921}
922
923pub fn prune_bm25_caches() -> PruneResult {
924    let mut result = PruneResult {
925        scanned: 0,
926        removed: 0,
927        bytes_freed: 0,
928    };
929
930    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
931        return result;
932    };
933    let vectors_dir = data_dir.join("vectors");
934    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
935        return result;
936    };
937
938    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
939
940    for entry in entries.flatten() {
941        let dir = entry.path();
942        if !dir.is_dir() {
943            continue;
944        }
945        result.scanned += 1;
946
947        for q_name in &[
948            "bm25_index.json.quarantined",
949            "bm25_index.bin.quarantined",
950            "bm25_index.bin.zst.quarantined",
951        ] {
952            let quarantined = dir.join(q_name);
953            if quarantined.exists() {
954                if let Ok(meta) = std::fs::metadata(&quarantined) {
955                    result.bytes_freed += meta.len();
956                }
957                let _ = std::fs::remove_file(&quarantined);
958                result.removed += 1;
959                println!("  Removed quarantined: {}", quarantined.display());
960            }
961        }
962
963        let index_path = if dir.join("bm25_index.bin.zst").exists() {
964            dir.join("bm25_index.bin.zst")
965        } else if dir.join("bm25_index.bin").exists() {
966            dir.join("bm25_index.bin")
967        } else {
968            dir.join("bm25_index.json")
969        };
970        if let Ok(meta) = std::fs::metadata(&index_path)
971            && meta.len() > max_bytes
972        {
973            result.bytes_freed += meta.len();
974            let _ = std::fs::remove_file(&index_path);
975            result.removed += 1;
976            println!(
977                "  Removed oversized ({:.1} MB): {}",
978                meta.len() as f64 / 1_048_576.0,
979                index_path.display()
980            );
981        }
982
983        let marker = dir.join("project_root.txt");
984        if let Ok(root_str) = std::fs::read_to_string(&marker) {
985            let root_path = std::path::Path::new(root_str.trim());
986            if !root_path.exists() {
987                let freed = dir_size(&dir);
988                result.bytes_freed += freed;
989                let _ = std::fs::remove_dir_all(&dir);
990                result.removed += 1;
991                println!(
992                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
993                    freed as f64 / 1_048_576.0,
994                    root_str.trim(),
995                    dir.display()
996                );
997            }
998        }
999    }
1000
1001    result
1002}
1003
1004pub fn prune_graph_caches() -> PruneResult {
1005    let mut result = PruneResult {
1006        scanned: 0,
1007        removed: 0,
1008        bytes_freed: 0,
1009    };
1010
1011    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1012        return result;
1013    };
1014    let graphs_dir = data_dir.join("graphs");
1015    let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
1016        return result;
1017    };
1018
1019    for entry in entries.flatten() {
1020        let dir = entry.path();
1021        if !dir.is_dir() {
1022            continue;
1023        }
1024        result.scanned += 1;
1025
1026        // #696 C4: the property graph (graph.db + graph.meta.json) is the sole
1027        // store. The meta carries the absolute project root, so an orphaned
1028        // `graphs/<hash>/` dir (project deleted) can still be pruned.
1029        let meta_file = dir.join("graph.meta.json");
1030        let db_file = dir.join("graph.db");
1031        if !meta_file.exists() && !db_file.exists() {
1032            continue;
1033        }
1034
1035        let root_from_meta = try_read_project_root_from_graph(&meta_file);
1036        if let Some(root) = root_from_meta
1037            && !root.is_empty()
1038            && !std::path::Path::new(&root).exists()
1039        {
1040            let freed = dir_size(&dir);
1041            result.bytes_freed += freed;
1042            let _ = std::fs::remove_dir_all(&dir);
1043            result.removed += 1;
1044            println!(
1045                "  Removed orphaned graph ({:.1} MB, project gone: {}): {}",
1046                freed as f64 / 1_048_576.0,
1047                root,
1048                dir.display()
1049            );
1050            continue;
1051        }
1052
1053        // Oversized guard: a pathologically large (e.g. corrupt) graph store is
1054        // dropped so the next query rebuilds it cleanly — a rebuild cost, not
1055        // data loss.
1056        if let Ok(meta) = std::fs::metadata(&db_file)
1057            && meta.len() > 100 * 1024 * 1024
1058        {
1059            let freed = dir_size(&dir);
1060            result.bytes_freed += freed;
1061            let _ = std::fs::remove_dir_all(&dir);
1062            result.removed += 1;
1063            println!(
1064                "  Removed oversized graph ({:.1} MB): {}",
1065                freed as f64 / 1_048_576.0,
1066                dir.display()
1067            );
1068        }
1069    }
1070
1071    result
1072}
1073
1074/// Read the absolute project root recorded in a `graph.meta.json` file, if
1075/// present (#696 C4 — replaces reading it from the retired JSON index).
1076fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
1077    let content = std::fs::read_to_string(path).ok()?;
1078    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
1079    val.get("project_root")?.as_str().map(String::from)
1080}
1081
1082pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
1083# Full reference: https://leanctx.com/docs/configuration
1084# For all settings: lean-ctx config init --full
1085
1086# ── High-Level Knobs ─────────────────────────────────────────────────
1087# These auto-adjust advanced settings. Override individual values below
1088# only if you need fine-grained control.
1089
1090# Output style for the model's prose (not tool-output compression):
1091#   off    — no style guidance
1092#   lite   — plain-English concise (default; readable, still token-saving)
1093#   standard / max — denser symbolic "power modes" (opt-in)
1094compression_level = "lite"
1095
1096# RAM/feature trade-off: low | balanced | performance
1097memory_profile = "balanced"
1098
1099# Maximum % of system RAM lean-ctx may use (1-50)
1100max_ram_percent = 5
1101
1102# Total disk budget in MB (0 = use individual limits).
1103# Distributes proportionally: archive ~25%, BM25 cache ~10%.
1104# max_disk_mb = 2000
1105
1106# Auto-purge data older than N days (0 = disabled).
1107# Flows into archive.max_age_hours.
1108# max_staleness_days = 30
1109
1110# Explicit project paths to scan/index (default: auto-detect).
1111# [ide_paths]
1112# cursor = ["/home/user/projects/app1"]
1113
1114# ── Proxy ────────────────────────────────────────────────────────────
1115# proxy_enabled = false
1116# proxy_port = 3128
1117"#;
1118
1119fn write_simplified_config() -> Result<String, String> {
1120    let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
1121    if let Some(dir) = path.parent() {
1122        std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
1123    }
1124    std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
1125    Ok(path.to_string_lossy().to_string())
1126}
1127
1128fn cmd_show_effective() {
1129    let cfg = config::Config::load();
1130    let compression = config::CompressionLevel::effective(&cfg);
1131    let policy = cfg.memory_policy_effective().unwrap_or_default();
1132
1133    println!("╭─── Simplified (high-level) ───────────────────────────────╮");
1134    println!(
1135        "│ compression_level   = {:10}  {}",
1136        format!("{compression:?}"),
1137        source_hint(
1138            "LEAN_CTX_COMPRESSION",
1139            cfg.compression_level != config::CompressionLevel::Off
1140        )
1141    );
1142    println!(
1143        "│ max_disk_mb         = {:10}  {}",
1144        cfg.max_disk_mb_effective(),
1145        source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
1146    );
1147    println!(
1148        "│ max_ram_percent     = {:10}  {}",
1149        cfg.max_ram_percent,
1150        source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
1151    );
1152    println!(
1153        "│ max_staleness_days  = {:10}  {}",
1154        cfg.max_staleness_days_effective(),
1155        source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
1156    );
1157    println!(
1158        "│ memory_profile      = {:10}  {}",
1159        format!("{:?}", cfg.memory_profile),
1160        source_hint("LEAN_CTX_MEMORY_PROFILE", false)
1161    );
1162    println!("╰────────────────────────────────────────────────────────────╯");
1163
1164    println!();
1165    println!("╭─── Derived effective limits ────────────────────────────────╮");
1166    println!(
1167        "│ archive_max_disk_mb    = {:>6} MB",
1168        cfg.archive_max_disk_mb_effective()
1169    );
1170    println!(
1171        "│ bm25_max_cache_mb      = {:>6} MB",
1172        cfg.bm25_max_cache_mb_effective()
1173    );
1174    println!(
1175        "│ archive_max_age_hours  = {:>6} h",
1176        cfg.archive_max_age_hours_effective()
1177    );
1178    println!(
1179        "│ graph_index_max_files  = {:>6}",
1180        cfg.graph_index_max_files
1181    );
1182    println!("│");
1183    println!(
1184        "│ memory.knowledge.max_facts     = {:>6}",
1185        policy.knowledge.max_facts
1186    );
1187    println!(
1188        "│ memory.knowledge.max_patterns  = {:>6}",
1189        policy.knowledge.max_patterns
1190    );
1191    println!(
1192        "│ memory.episodic.max_episodes   = {:>6}",
1193        policy.episodic.max_episodes
1194    );
1195    println!(
1196        "│ memory.procedural.max_procedures = {:>4}",
1197        policy.procedural.max_procedures
1198    );
1199    println!("╰────────────────────────────────────────────────────────────╯");
1200
1201    if cfg.max_disk_mb_effective() > 0 {
1202        println!();
1203        println!(
1204            "  ℹ  max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
1205            cfg.max_disk_mb_effective(),
1206            (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
1207        );
1208    }
1209}
1210
1211fn source_hint(env_var: &str, config_set: bool) -> &'static str {
1212    if std::env::var(env_var).is_ok() {
1213        "← env"
1214    } else if config_set {
1215        "← config"
1216    } else {
1217        "← default"
1218    }
1219}
1220
1221fn dir_size(path: &std::path::Path) -> u64 {
1222    let mut total = 0u64;
1223    if let Ok(entries) = std::fs::read_dir(path) {
1224        for entry in entries.flatten() {
1225            let p = entry.path();
1226            if p.is_file() {
1227                total += std::fs::metadata(&p).map_or(0, |m| m.len());
1228            } else if p.is_dir() {
1229                total += dir_size(&p);
1230            }
1231        }
1232    }
1233    total
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239
1240    // Reproduces `Config::save()`'s on-disk merge without touching the real
1241    // config path: serialize `cfg`, then merge it onto `existing` exactly as
1242    // save() does, and return the value that `max_ram_percent` ends up with.
1243    fn merged_max_ram(cfg: &config::Config, existing: &str) -> u8 {
1244        let dir = tempfile::tempdir().unwrap();
1245        let path = dir.path().join("config.toml");
1246        std::fs::write(&path, existing).unwrap();
1247        let new_content = toml::to_string_pretty(cfg).unwrap();
1248        let baseline = toml::from_str::<config::Config>("").unwrap();
1249        let defaults = toml::to_string_pretty(&baseline).unwrap();
1250        crate::config_io::write_toml_preserving_minimal(&path, &new_content, &defaults).unwrap();
1251        let written = std::fs::read_to_string(&path).unwrap();
1252        toml::from_str::<config::Config>(&written)
1253            .unwrap()
1254            .max_ram_percent
1255    }
1256
1257    #[test]
1258    fn full_init_uses_existing_values_not_defaults() {
1259        let existing = "max_ram_percent = 30\ncompression_level = \"standard\"\n";
1260        let cfg = config_for_full_init(Some(existing)).expect("parse existing");
1261        assert_eq!(cfg.max_ram_percent, 30, "must keep the user's value, not 5");
1262        assert_eq!(cfg.compression_level, config::CompressionLevel::Standard);
1263    }
1264
1265    #[test]
1266    fn full_init_falls_back_to_defaults_on_fresh_install() {
1267        let cfg = config_for_full_init(None).expect("default");
1268        assert_eq!(
1269            cfg.max_ram_percent,
1270            config::Config::default().max_ram_percent
1271        );
1272        let cfg_empty = config_for_full_init(Some("   \n")).expect("blank -> default");
1273        assert_eq!(
1274            cfg_empty.max_ram_percent,
1275            config::Config::default().max_ram_percent
1276        );
1277    }
1278
1279    #[test]
1280    fn full_init_refuses_unparseable_config() {
1281        assert!(config_for_full_init(Some("max_ram_percent = = =")).is_err());
1282    }
1283
1284    // #443 end-to-end: `config init --full` must not reset a customized value.
1285    #[test]
1286    fn full_init_preserves_value_through_save_merge() {
1287        let existing = "max_ram_percent = 30\n";
1288        let cfg = config_for_full_init(Some(existing)).unwrap();
1289        assert_eq!(
1290            merged_max_ram(&cfg, existing),
1291            30,
1292            "user value must survive `config init --full`"
1293        );
1294    }
1295
1296    // Guards the root cause: seeding the write from `Config::default()` (the old
1297    // behavior) DOES reset the value — proving why `config_for_full_init` must
1298    // load the existing config instead.
1299    #[test]
1300    fn default_seed_resets_value_root_cause_marker() {
1301        let existing = "max_ram_percent = 30\n";
1302        assert_eq!(
1303            merged_max_ram(&config::Config::default(), existing),
1304            5,
1305            "default seed resets to 5 — the #443 regression we fixed"
1306        );
1307    }
1308}