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