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