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