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 while
148/// preserving the user's existing values (#443).
149fn init_full_config() {
150    let existing_raw = config::Config::path().and_then(|p| std::fs::read_to_string(&p).ok());
151
152    let cfg = match config_for_full_init(existing_raw.as_deref()) {
153        Ok(cfg) => cfg,
154        Err(e) => {
155            eprintln!(
156                "Error: refusing to overwrite an unparseable config.toml ({e}).\n  \
157                 Fix it manually or run `lean-ctx doctor --fix`, then retry."
158            );
159            return;
160        }
161    };
162
163    match cfg.save() {
164        Ok(()) => {
165            let path = config::Config::path().map_or_else(
166                || "~/.lean-ctx/config.toml".to_string(),
167                |p| p.to_string_lossy().to_string(),
168            );
169            println!("Created full config at {path}");
170        }
171        Err(e) => eprintln!("Error: {e}"),
172    }
173}
174
175fn cmd_apply() {
176    use crate::daemon;
177    use crate::ipc;
178
179    println!("Applying config changes…");
180
181    // 1. Validate config first
182    println!("\n[1/4] Validating config…");
183    let schema = config::schema::ConfigSchema::generate();
184    let known = schema.known_keys();
185    let cfg = config::Config::load();
186
187    if let Some(path) = config::Config::path()
188        && path.exists()
189        && let Ok(raw) = std::fs::read_to_string(&path)
190        && let Ok(table) = raw.parse::<toml::Table>()
191    {
192        let mut user_keys = Vec::new();
193        fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
194            for (k, v) in table {
195                let full = if prefix.is_empty() {
196                    k.clone()
197                } else {
198                    format!("{prefix}.{k}")
199                };
200                if let toml::Value::Table(sub) = v {
201                    collect_flat(sub, &full, out);
202                } else {
203                    out.push(full);
204                }
205            }
206        }
207        collect_flat(&table, "", &mut user_keys);
208        let warnings: Vec<_> = user_keys
209            .iter()
210            .filter(|uk| {
211                !known.contains(uk) && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
212            })
213            .collect();
214        if warnings.is_empty() {
215            println!("  ✓ All config keys valid.");
216        } else {
217            for w in &warnings {
218                eprintln!("  [WARN] Unknown key: {w}");
219            }
220            eprintln!(
221                "  {} unknown key(s) found. Continuing anyway…",
222                warnings.len()
223            );
224        }
225    }
226
227    // 2. Restart processes
228    println!("\n[2/4] Restarting processes…");
229    crate::proxy_autostart::stop();
230
231    if let Err(e) = daemon::stop_daemon() {
232        eprintln!("  Warning: daemon stop: {e}");
233    }
234
235    let orphans = ipc::process::kill_all_by_name("lean-ctx");
236    if orphans > 0 {
237        println!("  Terminated {orphans} orphan process(es).");
238    }
239
240    std::thread::sleep(std::time::Duration::from_millis(500));
241
242    let remaining = ipc::process::find_pids_by_name("lean-ctx");
243    if !remaining.is_empty() {
244        for &pid in &remaining {
245            let _ = ipc::process::force_kill(pid);
246        }
247        std::thread::sleep(std::time::Duration::from_millis(300));
248    }
249
250    daemon::cleanup_daemon_files();
251    crate::proxy_autostart::start();
252
253    match daemon::start_daemon(&[]) {
254        Ok(()) => println!("  ✓ Daemon restarted."),
255        Err(e) => {
256            eprintln!("  ✗ Daemon start failed: {e}");
257            std::process::exit(1);
258        }
259    }
260
261    // 3. Safety checks
262    println!("\n[3/4] Running safety checks…");
263    println!("  RAM guard: max {}% system", cfg.max_ram_percent);
264
265    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
266        let sessions_dir = data_dir.join("sessions");
267        let session_count = std::fs::read_dir(&sessions_dir)
268            .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
269        println!("  Sessions dir: {session_count} files");
270    }
271
272    // 4. Summary
273    println!("\n[4/4] Config applied successfully.");
274    println!("  Theme:       {}", cfg.theme);
275    println!("  Ultra compact: {}", cfg.ultra_compact);
276    println!("  Checkpoint:  every {} calls", cfg.checkpoint_interval);
277    if let Some(ref root) = cfg.project_root {
278        println!("  Project root: {root}");
279    }
280}
281
282fn cmd_validate() {
283    let schema = config::schema::ConfigSchema::generate();
284    let known = schema.known_keys();
285
286    let path = match config::Config::path() {
287        Some(p) if p.exists() => p,
288        _ => {
289            println!("[OK] No config.toml found — using defaults.");
290            return;
291        }
292    };
293
294    let raw = match std::fs::read_to_string(&path) {
295        Ok(s) => s,
296        Err(e) => {
297            eprintln!("[ERROR] Cannot read {}: {e}", path.display());
298            std::process::exit(1);
299        }
300    };
301
302    let table: toml::Table = match raw.parse() {
303        Ok(t) => t,
304        Err(e) => {
305            eprintln!("[ERROR] Invalid TOML: {e}");
306            std::process::exit(1);
307        }
308    };
309
310    let mut warnings = 0u32;
311    let mut validated = 0u32;
312
313    fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
314        for (k, v) in table {
315            let full = if prefix.is_empty() {
316                k.clone()
317            } else {
318                format!("{prefix}.{k}")
319            };
320            match v {
321                toml::Value::Table(sub) => collect_keys(sub, &full, out),
322                toml::Value::Array(arr) => {
323                    out.push(full.clone());
324                    for item in arr {
325                        if let toml::Value::Table(sub) = item {
326                            for sk in sub.keys() {
327                                out.push(format!("{full}[].{sk}"));
328                            }
329                        }
330                    }
331                }
332                _ => out.push(full),
333            }
334        }
335    }
336
337    let mut user_keys = Vec::new();
338    collect_keys(&table, "", &mut user_keys);
339
340    for uk in &user_keys {
341        let base = uk.split("[].").next().unwrap_or(uk);
342        let field = uk.rsplit("[].").next().unwrap_or("");
343        let check_key = if uk.contains("[].") {
344            format!("{base}.{field}")
345        } else {
346            uk.clone()
347        };
348
349        if known.contains(&check_key)
350            || known
351                .iter()
352                .any(|k| check_key.starts_with(&format!("{k}.")))
353        {
354            validated += 1;
355        } else {
356            warnings += 1;
357            let suggestion = find_closest(&check_key, &known);
358            if let Some(sug) = suggestion {
359                eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
360            } else {
361                eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
362            }
363        }
364    }
365
366    let cfg = config::Config::load();
367    let budget = cfg.max_disk_mb_effective();
368    if budget > 0 {
369        let explicit_archive = cfg.archive.max_disk_mb;
370        let explicit_bm25 = cfg.bm25_max_cache_mb;
371        let sum = explicit_archive + explicit_bm25;
372        if sum > budget {
373            warnings += 1;
374            println!(
375                "  ⚠ max_disk_mb={budget} but archive.max_disk_mb({explicit_archive}) + bm25_max_cache_mb({explicit_bm25}) = {sum} exceeds budget"
376            );
377        }
378    }
379
380    let total = validated + warnings;
381    if warnings == 0 {
382        println!(
383            "[OK] All {total} keys validated successfully ({}).",
384            path.display()
385        );
386    } else {
387        println!(
388            "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
389            path.display()
390        );
391        std::process::exit(1);
392    }
393}
394
395fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
396    let mut best: Option<(usize, &str)> = None;
397    for candidate in haystack {
398        let d = levenshtein(needle, candidate);
399        if d <= 3 && (best.is_none() || d < best.unwrap().0) {
400            best = Some((d, candidate));
401        }
402    }
403    if best.is_some() {
404        return best.map(|(_, s)| s.to_string());
405    }
406    let leaf = needle.rsplit('.').next().unwrap_or(needle);
407    let mut leaf_best: Option<(usize, &str)> = None;
408    for candidate in haystack {
409        let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
410        let d = levenshtein(leaf, cand_leaf);
411        if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
412            leaf_best = Some((d, candidate));
413        }
414    }
415    leaf_best.map(|(_, s)| s.to_string())
416}
417
418fn levenshtein(a: &str, b: &str) -> usize {
419    let a: Vec<char> = a.chars().collect();
420    let b: Vec<char> = b.chars().collect();
421    let (m, n) = (a.len(), b.len());
422    let mut dp = vec![vec![0usize; n + 1]; m + 1];
423    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
424        row[0] = i;
425    }
426    for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
427        *val = j;
428    }
429    for i in 1..=m {
430        for j in 1..=n {
431            let cost = usize::from(a[i - 1] != b[j - 1]);
432            dp[i][j] = (dp[i - 1][j] + 1)
433                .min(dp[i][j - 1] + 1)
434                .min(dp[i - 1][j - 1] + cost);
435        }
436    }
437    dp[m][n]
438}
439
440fn normalize_optional_upstream(value: &str) -> Option<String> {
441    use crate::core::config::normalize_url_opt;
442    let trimmed = value.trim();
443    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
444        None
445    } else {
446        normalize_url_opt(trimmed)
447    }
448}
449
450pub fn cmd_benchmark(args: &[String]) {
451    use crate::core::benchmark;
452    use crate::core::benchmark_compare;
453
454    let action = args.first().map_or("run", std::string::String::as_str);
455
456    match action {
457        "--help" | "-h" => {
458            println!("Usage: lean-ctx benchmark run [path] [--json]");
459            println!("       lean-ctx benchmark report [path]");
460            println!("       lean-ctx benchmark eval [path] [--json]");
461            println!("       lean-ctx benchmark compare [--repo path] [--output file.md]");
462            println!("       lean-ctx benchmark scorecard [--json] [--output file]");
463        }
464        "scorecard" => {
465            let is_json = args.iter().any(|a| a == "--json");
466            let output = parse_flag_value(args, "--output");
467            match crate::core::scorecard::run_scorecard() {
468                Ok(sc) => {
469                    let rendered = if is_json { sc.to_json() } else { sc.to_human() };
470                    if let Some(path) = output {
471                        if let Err(e) = std::fs::write(&path, &rendered) {
472                            eprintln!("Failed to write scorecard to {path}: {e}");
473                            std::process::exit(1);
474                        }
475                        eprintln!("Wrote scorecard to {path}");
476                    } else {
477                        print!("{rendered}");
478                    }
479                }
480                Err(e) => {
481                    eprintln!("Scorecard failed: {e}");
482                    std::process::exit(1);
483                }
484            }
485        }
486        "eval" => {
487            let path = args.get(1).map_or(".", std::string::String::as_str);
488            let is_json = args.iter().any(|a| a == "--json");
489            let root = std::path::Path::new(path);
490
491            let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
492            let cfg = crate::core::hybrid_search::HybridConfig::from_config();
493            let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
494
495            if queries.is_empty() {
496                eprintln!("No symbols found — cannot generate eval queries.");
497                std::process::exit(1);
498            }
499
500            let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
501            if is_json {
502                if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
503                    println!("{json}");
504                }
505            } else {
506                print!("{scorecard}");
507            }
508        }
509        "run" => {
510            let path = args.get(1).map_or(".", std::string::String::as_str);
511            let is_json = args.iter().any(|a| a == "--json");
512
513            let result = benchmark::run_project_benchmark(path);
514            if is_json {
515                println!("{}", benchmark::format_json(&result));
516            } else {
517                println!("{}", benchmark::format_terminal(&result));
518            }
519        }
520        "report" => {
521            let path = args.get(1).map_or(".", std::string::String::as_str);
522            let result = benchmark::run_project_benchmark(path);
523            println!("{}", benchmark::format_markdown(&result));
524        }
525        "compare" => {
526            let repo = parse_flag_value(args, "--repo").unwrap_or_else(|| ".".to_string());
527            let output = parse_flag_value(args, "--output");
528
529            let root = std::path::Path::new(&repo);
530            if !root.exists() {
531                eprintln!("Repository path does not exist: {repo}");
532                std::process::exit(1);
533            }
534
535            let report = benchmark_compare::run_compare(root, output.as_deref());
536
537            println!("{}", benchmark_compare::report::generate_terminal(&report));
538
539            if output.is_none() {
540                eprintln!("Tip: use --output BENCHMARKS.md to save the full markdown report");
541            }
542        }
543        _ => {
544            if std::path::Path::new(action).exists() {
545                let result = benchmark::run_project_benchmark(action);
546                println!("{}", benchmark::format_terminal(&result));
547            } else {
548                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
549                eprintln!("       lean-ctx benchmark report [path]");
550                eprintln!("       lean-ctx benchmark eval [path] [--json]");
551                eprintln!("       lean-ctx benchmark compare [--repo path] [--output file.md]");
552                eprintln!("       lean-ctx benchmark scorecard [--json] [--output file]");
553                std::process::exit(1);
554            }
555        }
556    }
557}
558
559fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
560    args.iter()
561        .position(|a| a == flag)
562        .and_then(|i| args.get(i + 1))
563        .cloned()
564}
565
566pub fn cmd_stats(args: &[String]) {
567    match args.first().map(std::string::String::as_str) {
568        Some("reset-cep") => {
569            crate::core::stats::reset_cep();
570            println!("CEP stats reset. Shell hook data preserved.");
571        }
572        Some("json") => {
573            let store = crate::core::stats::load();
574            println!(
575                "{}",
576                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
577            );
578        }
579        _ => {
580            let store = crate::core::stats::load();
581            let input_saved = store
582                .total_input_tokens
583                .saturating_sub(store.total_output_tokens);
584            let pct = if store.total_input_tokens > 0 {
585                input_saved as f64 / store.total_input_tokens as f64 * 100.0
586            } else {
587                0.0
588            };
589            println!("Commands:    {}", store.total_commands);
590            println!("Input:       {} tokens", store.total_input_tokens);
591            println!("Output:      {} tokens", store.total_output_tokens);
592            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
593            println!();
594            println!("CEP sessions:  {}", store.cep.sessions);
595            println!(
596                "CEP tokens:    {} → {}",
597                store.cep.total_tokens_original, store.cep.total_tokens_compressed
598            );
599            println!();
600            println!("Subcommands: stats reset-cep | stats json");
601        }
602    }
603}
604
605pub fn cmd_cache(args: &[String]) {
606    use crate::core::cli_cache;
607    match args.first().map(std::string::String::as_str) {
608        Some("clear") => {
609            let count = cli_cache::clear();
610            println!("Cleared {count} cached entries.");
611        }
612        Some("reset") => {
613            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
614            if project_flag {
615                let root =
616                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
617                if let Some(root) = root {
618                    let count = cli_cache::clear_project(&root);
619                    println!("Reset {count} cache entries for project: {root}");
620                } else {
621                    eprintln!("No active project root found. Start a session first.");
622                    std::process::exit(1);
623                }
624            } else {
625                let count = cli_cache::clear();
626                println!("Reset all {count} cache entries.");
627            }
628        }
629        Some("stats") => {
630            let (hits, reads, entries) = cli_cache::stats();
631            let rate = if reads > 0 {
632                (hits as f64 / reads as f64 * 100.0).round() as u32
633            } else {
634                0
635            };
636            println!("CLI Cache Stats (lean-ctx read / lean-ctx grep):");
637            println!("  Entries:   {entries}");
638            println!("  Reads:     {reads}");
639            println!("  Hits:      {hits}");
640            println!("  Hit Rate:  {rate}%");
641
642            if let Ok(dir) = crate::core::paths::state_dir() {
643                let live_path = dir.join("mcp-live.json");
644                if let Ok(content) = std::fs::read_to_string(&live_path) {
645                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
646                        let mcp_reads = val
647                            .get("total_reads")
648                            .and_then(serde_json::Value::as_u64)
649                            .unwrap_or(0);
650                        let mcp_hits = val
651                            .get("cache_hits")
652                            .and_then(serde_json::Value::as_u64)
653                            .unwrap_or(0);
654                        let mcp_saved = val
655                            .get("tokens_saved")
656                            .and_then(serde_json::Value::as_u64)
657                            .unwrap_or(0);
658                        let mcp_rate = if mcp_reads > 0 {
659                            (mcp_hits as f64 / mcp_reads as f64 * 100.0).round() as u32
660                        } else {
661                            0
662                        };
663                        let updated = val
664                            .get("updated_at")
665                            .and_then(serde_json::Value::as_str)
666                            .unwrap_or("unknown");
667                        println!();
668                        println!("MCP Session Cache (ctx_read via AI editor):");
669                        println!("  Reads:         {mcp_reads}");
670                        println!("  Hits:          {mcp_hits}");
671                        println!("  Hit Rate:      {mcp_rate}%");
672                        println!("  Tokens Saved:  {mcp_saved}");
673                        println!("  Last Updated:  {updated}");
674                    }
675                } else {
676                    println!();
677                    println!(
678                        "MCP Session Cache: no data yet (start a session with your AI editor)"
679                    );
680                }
681            }
682        }
683        Some("invalidate") => {
684            if args.len() < 2 {
685                eprintln!("Usage: lean-ctx cache invalidate <path>");
686                std::process::exit(1);
687            }
688            cli_cache::invalidate(&args[1]);
689            println!("Invalidated cache for {}", args[1]);
690        }
691        Some("prune") => {
692            let bm25 = prune_bm25_caches();
693            let graph = prune_graph_caches();
694            // Enforce the archive TTL + on-disk size budget alongside the index
695            // caches so a manual prune reclaims the (often largest) store too (#417).
696            let archive_before = crate::core::archive::disk_usage_bytes()
697                + crate::core::archive_fts::db_size_bytes();
698            let archive_removed = crate::core::archive::cleanup();
699            let _ = crate::core::archive_fts::enforce_cap();
700            let archive_after = crate::core::archive::disk_usage_bytes()
701                + crate::core::archive_fts::db_size_bytes();
702            let archive_freed = archive_before.saturating_sub(archive_after);
703
704            // Reclaim knowledge stores whose project_root was deleted (removed
705            // worktrees, thrown-away projects): they can never be written again,
706            // so their per-store eviction cap can never self-heal — pure bloat (#615).
707            let orphans = crate::core::knowledge::maintenance::prune_orphaned_stores();
708
709            let removed = bm25.removed + graph.removed + archive_removed + orphans.removed as u32;
710            let freed =
711                bm25.bytes_freed + graph.bytes_freed + archive_freed + orphans.reclaimed_bytes;
712            println!(
713                "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {}, archive: {}, orphaned stores: {})",
714                removed,
715                freed as f64 / 1_048_576.0,
716                bm25.removed,
717                graph.removed,
718                archive_removed,
719                orphans.removed,
720            );
721        }
722        _ => {
723            let (hits, reads, entries) = cli_cache::stats();
724            let rate = if reads > 0 {
725                (hits as f64 / reads as f64 * 100.0).round() as u32
726            } else {
727                0
728            };
729            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
730            println!();
731            println!("Subcommands:");
732            println!("  cache stats       Show detailed stats");
733            println!("  cache clear       Clear all cached entries");
734            println!("  cache reset       Reset all cache (or --project for current project only)");
735            println!("  cache invalidate  Remove specific file from cache");
736            println!(
737                "  cache prune       Reclaim BM25 + graph indexes, archive, and orphaned knowledge stores"
738            );
739        }
740    }
741}
742
743pub struct PruneResult {
744    pub scanned: u32,
745    pub removed: u32,
746    pub bytes_freed: u64,
747}
748
749pub fn prune_bm25_caches() -> PruneResult {
750    let mut result = PruneResult {
751        scanned: 0,
752        removed: 0,
753        bytes_freed: 0,
754    };
755
756    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
757        return result;
758    };
759    let vectors_dir = data_dir.join("vectors");
760    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
761        return result;
762    };
763
764    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
765
766    for entry in entries.flatten() {
767        let dir = entry.path();
768        if !dir.is_dir() {
769            continue;
770        }
771        result.scanned += 1;
772
773        for q_name in &[
774            "bm25_index.json.quarantined",
775            "bm25_index.bin.quarantined",
776            "bm25_index.bin.zst.quarantined",
777        ] {
778            let quarantined = dir.join(q_name);
779            if quarantined.exists() {
780                if let Ok(meta) = std::fs::metadata(&quarantined) {
781                    result.bytes_freed += meta.len();
782                }
783                let _ = std::fs::remove_file(&quarantined);
784                result.removed += 1;
785                println!("  Removed quarantined: {}", quarantined.display());
786            }
787        }
788
789        let index_path = if dir.join("bm25_index.bin.zst").exists() {
790            dir.join("bm25_index.bin.zst")
791        } else if dir.join("bm25_index.bin").exists() {
792            dir.join("bm25_index.bin")
793        } else {
794            dir.join("bm25_index.json")
795        };
796        if let Ok(meta) = std::fs::metadata(&index_path)
797            && meta.len() > max_bytes
798        {
799            result.bytes_freed += meta.len();
800            let _ = std::fs::remove_file(&index_path);
801            result.removed += 1;
802            println!(
803                "  Removed oversized ({:.1} MB): {}",
804                meta.len() as f64 / 1_048_576.0,
805                index_path.display()
806            );
807        }
808
809        let marker = dir.join("project_root.txt");
810        if let Ok(root_str) = std::fs::read_to_string(&marker) {
811            let root_path = std::path::Path::new(root_str.trim());
812            if !root_path.exists() {
813                let freed = dir_size(&dir);
814                result.bytes_freed += freed;
815                let _ = std::fs::remove_dir_all(&dir);
816                result.removed += 1;
817                println!(
818                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
819                    freed as f64 / 1_048_576.0,
820                    root_str.trim(),
821                    dir.display()
822                );
823            }
824        }
825    }
826
827    result
828}
829
830pub fn prune_graph_caches() -> PruneResult {
831    let mut result = PruneResult {
832        scanned: 0,
833        removed: 0,
834        bytes_freed: 0,
835    };
836
837    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
838        return result;
839    };
840    let graphs_dir = data_dir.join("graphs");
841    let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
842        return result;
843    };
844
845    for entry in entries.flatten() {
846        let dir = entry.path();
847        if !dir.is_dir() {
848            continue;
849        }
850        result.scanned += 1;
851
852        let index_path = dir.join("index.json.zst");
853        let index_json = dir.join("index.json");
854
855        let has_index = index_path.exists() || index_json.exists();
856        if !has_index {
857            continue;
858        }
859
860        let idx_file = if index_path.exists() {
861            &index_path
862        } else {
863            &index_json
864        };
865
866        let root_from_index = try_read_project_root_from_graph(idx_file);
867        if let Some(root) = root_from_index
868            && !root.is_empty()
869            && !std::path::Path::new(&root).exists()
870        {
871            let freed = dir_size(&dir);
872            result.bytes_freed += freed;
873            let _ = std::fs::remove_dir_all(&dir);
874            result.removed += 1;
875            println!(
876                "  Removed orphaned graph ({:.1} MB, project gone: {}): {}",
877                freed as f64 / 1_048_576.0,
878                root,
879                dir.display()
880            );
881            continue;
882        }
883
884        if let Ok(meta) = std::fs::metadata(idx_file)
885            && meta.len() > 100 * 1024 * 1024
886        {
887            result.bytes_freed += meta.len();
888            let _ = std::fs::remove_file(idx_file);
889            result.removed += 1;
890            println!(
891                "  Removed oversized graph ({:.1} MB): {}",
892                meta.len() as f64 / 1_048_576.0,
893                idx_file.display()
894            );
895        }
896    }
897
898    result
899}
900
901fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
902    let data = if path.extension().and_then(|e| e.to_str()) == Some("zst") {
903        let compressed = std::fs::read(path).ok()?;
904        zstd::decode_all(compressed.as_slice()).ok()?
905    } else {
906        std::fs::read(path).ok()?
907    };
908    let content = String::from_utf8(data).ok()?;
909    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
910    val.get("project_root")?.as_str().map(String::from)
911}
912
913pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
914# Full reference: https://leanctx.com/docs/configuration
915# For all settings: lean-ctx config init --full
916
917# ── High-Level Knobs ─────────────────────────────────────────────────
918# These auto-adjust advanced settings. Override individual values below
919# only if you need fine-grained control.
920
921# Output style for the model's prose (not tool-output compression):
922#   off    — no style guidance
923#   lite   — plain-English concise (default; readable, still token-saving)
924#   standard / max — denser symbolic "power modes" (opt-in)
925compression_level = "lite"
926
927# RAM/feature trade-off: low | balanced | performance
928memory_profile = "balanced"
929
930# Maximum % of system RAM lean-ctx may use (1-50)
931max_ram_percent = 5
932
933# Total disk budget in MB (0 = use individual limits).
934# Distributes proportionally: archive ~25%, BM25 cache ~10%.
935# max_disk_mb = 2000
936
937# Auto-purge data older than N days (0 = disabled).
938# Flows into archive.max_age_hours.
939# max_staleness_days = 30
940
941# Explicit project paths to scan/index (default: auto-detect).
942# [ide_paths]
943# cursor = ["/home/user/projects/app1"]
944
945# ── Proxy ────────────────────────────────────────────────────────────
946# proxy_enabled = false
947# proxy_port = 3128
948"#;
949
950fn write_simplified_config() -> Result<String, String> {
951    let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
952    if let Some(dir) = path.parent() {
953        std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
954    }
955    std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
956    Ok(path.to_string_lossy().to_string())
957}
958
959fn cmd_show_effective() {
960    let cfg = config::Config::load();
961    let compression = config::CompressionLevel::effective(&cfg);
962    let policy = cfg.memory_policy_effective().unwrap_or_default();
963
964    println!("╭─── Simplified (high-level) ───────────────────────────────╮");
965    println!(
966        "│ compression_level   = {:10}  {}",
967        format!("{compression:?}"),
968        source_hint(
969            "LEAN_CTX_COMPRESSION",
970            cfg.compression_level != config::CompressionLevel::Off
971        )
972    );
973    println!(
974        "│ max_disk_mb         = {:10}  {}",
975        cfg.max_disk_mb_effective(),
976        source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
977    );
978    println!(
979        "│ max_ram_percent     = {:10}  {}",
980        cfg.max_ram_percent,
981        source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
982    );
983    println!(
984        "│ max_staleness_days  = {:10}  {}",
985        cfg.max_staleness_days_effective(),
986        source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
987    );
988    println!(
989        "│ memory_profile      = {:10}  {}",
990        format!("{:?}", cfg.memory_profile),
991        source_hint("LEAN_CTX_MEMORY_PROFILE", false)
992    );
993    println!("╰────────────────────────────────────────────────────────────╯");
994
995    println!();
996    println!("╭─── Derived effective limits ────────────────────────────────╮");
997    println!(
998        "│ archive_max_disk_mb    = {:>6} MB",
999        cfg.archive_max_disk_mb_effective()
1000    );
1001    println!(
1002        "│ bm25_max_cache_mb      = {:>6} MB",
1003        cfg.bm25_max_cache_mb_effective()
1004    );
1005    println!(
1006        "│ archive_max_age_hours  = {:>6} h",
1007        cfg.archive_max_age_hours_effective()
1008    );
1009    println!(
1010        "│ graph_index_max_files  = {:>6}",
1011        cfg.graph_index_max_files
1012    );
1013    println!("│");
1014    println!(
1015        "│ memory.knowledge.max_facts     = {:>6}",
1016        policy.knowledge.max_facts
1017    );
1018    println!(
1019        "│ memory.knowledge.max_patterns  = {:>6}",
1020        policy.knowledge.max_patterns
1021    );
1022    println!(
1023        "│ memory.episodic.max_episodes   = {:>6}",
1024        policy.episodic.max_episodes
1025    );
1026    println!(
1027        "│ memory.procedural.max_procedures = {:>4}",
1028        policy.procedural.max_procedures
1029    );
1030    println!("╰────────────────────────────────────────────────────────────╯");
1031
1032    if cfg.max_disk_mb_effective() > 0 {
1033        println!();
1034        println!(
1035            "  ℹ  max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
1036            cfg.max_disk_mb_effective(),
1037            (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
1038        );
1039    }
1040}
1041
1042fn source_hint(env_var: &str, config_set: bool) -> &'static str {
1043    if std::env::var(env_var).is_ok() {
1044        "← env"
1045    } else if config_set {
1046        "← config"
1047    } else {
1048        "← default"
1049    }
1050}
1051
1052fn dir_size(path: &std::path::Path) -> u64 {
1053    let mut total = 0u64;
1054    if let Ok(entries) = std::fs::read_dir(path) {
1055        for entry in entries.flatten() {
1056            let p = entry.path();
1057            if p.is_file() {
1058                total += std::fs::metadata(&p).map_or(0, |m| m.len());
1059            } else if p.is_dir() {
1060                total += dir_size(&p);
1061            }
1062        }
1063    }
1064    total
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070
1071    // Reproduces `Config::save()`'s on-disk merge without touching the real
1072    // config path: serialize `cfg`, then merge it onto `existing` exactly as
1073    // save() does, and return the value that `max_ram_percent` ends up with.
1074    fn merged_max_ram(cfg: &config::Config, existing: &str) -> u8 {
1075        let dir = tempfile::tempdir().unwrap();
1076        let path = dir.path().join("config.toml");
1077        std::fs::write(&path, existing).unwrap();
1078        let new_content = toml::to_string_pretty(cfg).unwrap();
1079        let baseline = toml::from_str::<config::Config>("").unwrap();
1080        let defaults = toml::to_string_pretty(&baseline).unwrap();
1081        crate::config_io::write_toml_preserving_minimal(&path, &new_content, &defaults).unwrap();
1082        let written = std::fs::read_to_string(&path).unwrap();
1083        toml::from_str::<config::Config>(&written)
1084            .unwrap()
1085            .max_ram_percent
1086    }
1087
1088    #[test]
1089    fn full_init_uses_existing_values_not_defaults() {
1090        let existing = "max_ram_percent = 30\ncompression_level = \"standard\"\n";
1091        let cfg = config_for_full_init(Some(existing)).expect("parse existing");
1092        assert_eq!(cfg.max_ram_percent, 30, "must keep the user's value, not 5");
1093        assert_eq!(cfg.compression_level, config::CompressionLevel::Standard);
1094    }
1095
1096    #[test]
1097    fn full_init_falls_back_to_defaults_on_fresh_install() {
1098        let cfg = config_for_full_init(None).expect("default");
1099        assert_eq!(
1100            cfg.max_ram_percent,
1101            config::Config::default().max_ram_percent
1102        );
1103        let cfg_empty = config_for_full_init(Some("   \n")).expect("blank -> default");
1104        assert_eq!(
1105            cfg_empty.max_ram_percent,
1106            config::Config::default().max_ram_percent
1107        );
1108    }
1109
1110    #[test]
1111    fn full_init_refuses_unparseable_config() {
1112        assert!(config_for_full_init(Some("max_ram_percent = = =")).is_err());
1113    }
1114
1115    // #443 end-to-end: `config init --full` must not reset a customized value.
1116    #[test]
1117    fn full_init_preserves_value_through_save_merge() {
1118        let existing = "max_ram_percent = 30\n";
1119        let cfg = config_for_full_init(Some(existing)).unwrap();
1120        assert_eq!(
1121            merged_max_ram(&cfg, existing),
1122            30,
1123            "user value must survive `config init --full`"
1124        );
1125    }
1126
1127    // Guards the root cause: seeding the write from `Config::default()` (the old
1128    // behavior) DOES reset the value — proving why `config_for_full_init` must
1129    // load the existing config instead.
1130    #[test]
1131    fn default_seed_resets_value_root_cause_marker() {
1132        let existing = "max_ram_percent = 30\n";
1133        assert_eq!(
1134            merged_max_ram(&config::Config::default(), existing),
1135            5,
1136            "default seed resets to 5 — the #443 regression we fixed"
1137        );
1138    }
1139}