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                let default = config::Config::default();
20                match default.save() {
21                    Ok(()) => {
22                        let path = config::Config::path().map_or_else(
23                            || "~/.lean-ctx/config.toml".to_string(),
24                            |p| p.to_string_lossy().to_string(),
25                        );
26                        println!("Created full config at {path}");
27                    }
28                    Err(e) => eprintln!("Error: {e}"),
29                }
30            } else {
31                match write_simplified_config() {
32                    Ok(path) => println!("Created simplified config at {path}"),
33                    Err(e) => eprintln!("Error: {e}"),
34                }
35            }
36        }
37        "set" => {
38            if args.len() < 3 {
39                eprintln!("Usage: lean-ctx config set <key> <value>");
40                std::process::exit(1);
41            }
42            let key = &args[1];
43            let val = &args[2];
44
45            // Special validation hooks for keys that need custom logic
46            // beyond what the schema type system can express.
47            match key.as_str() {
48                "theme" if theme::from_preset(val).is_none() && val != "custom" => {
49                    eprintln!(
50                        "Unknown theme '{val}'. Available: {}",
51                        theme::PRESET_NAMES.join(", ")
52                    );
53                    std::process::exit(1);
54                }
55                "tee_on_error" | "tee_mode" => {
56                    let normalized = match val.as_str() {
57                        "true" => "failures",
58                        "false" => "never",
59                        other => other,
60                    };
61                    match config::setter::set_by_key("tee_mode", normalized) {
62                        Ok(_) => println!("Updated {key} = {val}"),
63                        Err(e) => {
64                            eprintln!("{e}");
65                            std::process::exit(1);
66                        }
67                    }
68                    return;
69                }
70                "project_root" => {
71                    let path = std::path::Path::new(val.as_str());
72                    if !path.exists() || !path.is_dir() {
73                        eprintln!("Error: '{val}' is not an existing directory.");
74                        std::process::exit(1);
75                    }
76                }
77                "embedding.model"
78                    if crate::core::embeddings::model_registry::EmbeddingModel::from_str_name(
79                        val,
80                    )
81                    .is_none() =>
82                {
83                    eprintln!(
84                        "Unknown embedding model '{val}'. Available: minilm (default), \
85                         jina-code-v2, nomic — or hf:org/repo[@revision] for any HuggingFace \
86                         repo with an ONNX export (see docs/guides/custom-embeddings.md)."
87                    );
88                    std::process::exit(1);
89                }
90                "proxy.anthropic_upstream" | "proxy.openai_upstream" | "proxy.gemini_upstream" => {
91                    let normalized = normalize_optional_upstream(val);
92                    let effective = normalized.as_deref().unwrap_or("");
93                    match config::setter::set_by_key(key, effective) {
94                        Ok(_) => println!("Updated {key} = {val}"),
95                        Err(e) => {
96                            eprintln!("{e}");
97                            std::process::exit(1);
98                        }
99                    }
100                    return;
101                }
102                _ => {}
103            }
104
105            // Generic schema-based setter handles all keys
106            match config::setter::set_by_key(key, val) {
107                Ok(_) => println!("Updated {key} = {val}"),
108                Err(e) => {
109                    eprintln!("{e}");
110                    std::process::exit(1);
111                }
112            }
113        }
114        "schema" => {
115            let schema = config::schema::ConfigSchema::generate();
116            println!(
117                "{}",
118                serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
119            );
120        }
121        "validate" => {
122            cmd_validate();
123        }
124        "show" | "effective" => {
125            cmd_show_effective();
126        }
127        "apply" | "reload" => {
128            cmd_apply();
129        }
130        _ => {
131            eprintln!("Usage: lean-ctx config [init|set|show|schema|validate|apply]");
132            std::process::exit(1);
133        }
134    }
135}
136
137fn cmd_apply() {
138    use crate::daemon;
139    use crate::ipc;
140
141    println!("Applying config changes…");
142
143    // 1. Validate config first
144    println!("\n[1/4] Validating config…");
145    let schema = config::schema::ConfigSchema::generate();
146    let known = schema.known_keys();
147    let cfg = config::Config::load();
148
149    if let Some(path) = config::Config::path() {
150        if path.exists() {
151            if let Ok(raw) = std::fs::read_to_string(&path) {
152                if let Ok(table) = raw.parse::<toml::Table>() {
153                    let mut user_keys = Vec::new();
154                    fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
155                        for (k, v) in table {
156                            let full = if prefix.is_empty() {
157                                k.clone()
158                            } else {
159                                format!("{prefix}.{k}")
160                            };
161                            if let toml::Value::Table(sub) = v {
162                                collect_flat(sub, &full, out);
163                            } else {
164                                out.push(full);
165                            }
166                        }
167                    }
168                    collect_flat(&table, "", &mut user_keys);
169                    let warnings: Vec<_> = user_keys
170                        .iter()
171                        .filter(|uk| {
172                            !known.contains(uk)
173                                && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
174                        })
175                        .collect();
176                    if warnings.is_empty() {
177                        println!("  ✓ All config keys valid.");
178                    } else {
179                        for w in &warnings {
180                            eprintln!("  [WARN] Unknown key: {w}");
181                        }
182                        eprintln!(
183                            "  {} unknown key(s) found. Continuing anyway…",
184                            warnings.len()
185                        );
186                    }
187                }
188            }
189        }
190    }
191
192    // 2. Restart processes
193    println!("\n[2/4] Restarting processes…");
194    crate::proxy_autostart::stop();
195
196    if let Err(e) = daemon::stop_daemon() {
197        eprintln!("  Warning: daemon stop: {e}");
198    }
199
200    let orphans = ipc::process::kill_all_by_name("lean-ctx");
201    if orphans > 0 {
202        println!("  Terminated {orphans} orphan process(es).");
203    }
204
205    std::thread::sleep(std::time::Duration::from_millis(500));
206
207    let remaining = ipc::process::find_pids_by_name("lean-ctx");
208    if !remaining.is_empty() {
209        for &pid in &remaining {
210            let _ = ipc::process::force_kill(pid);
211        }
212        std::thread::sleep(std::time::Duration::from_millis(300));
213    }
214
215    daemon::cleanup_daemon_files();
216    crate::proxy_autostart::start();
217
218    match daemon::start_daemon(&[]) {
219        Ok(()) => println!("  ✓ Daemon restarted."),
220        Err(e) => {
221            eprintln!("  ✗ Daemon start failed: {e}");
222            std::process::exit(1);
223        }
224    }
225
226    // 3. Safety checks
227    println!("\n[3/4] Running safety checks…");
228    println!("  RAM guard: max {}% system", cfg.max_ram_percent);
229
230    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
231        let sessions_dir = data_dir.join("sessions");
232        let session_count = std::fs::read_dir(&sessions_dir)
233            .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
234        println!("  Sessions dir: {session_count} files");
235    }
236
237    // 4. Summary
238    println!("\n[4/4] Config applied successfully.");
239    println!("  Theme:       {}", cfg.theme);
240    println!("  Ultra compact: {}", cfg.ultra_compact);
241    println!("  Checkpoint:  every {} calls", cfg.checkpoint_interval);
242    if let Some(ref root) = cfg.project_root {
243        println!("  Project root: {root}");
244    }
245}
246
247fn cmd_validate() {
248    let schema = config::schema::ConfigSchema::generate();
249    let known = schema.known_keys();
250
251    let path = match config::Config::path() {
252        Some(p) if p.exists() => p,
253        _ => {
254            println!("[OK] No config.toml found — using defaults.");
255            return;
256        }
257    };
258
259    let raw = match std::fs::read_to_string(&path) {
260        Ok(s) => s,
261        Err(e) => {
262            eprintln!("[ERROR] Cannot read {}: {e}", path.display());
263            std::process::exit(1);
264        }
265    };
266
267    let table: toml::Table = match raw.parse() {
268        Ok(t) => t,
269        Err(e) => {
270            eprintln!("[ERROR] Invalid TOML: {e}");
271            std::process::exit(1);
272        }
273    };
274
275    let mut warnings = 0u32;
276    let mut validated = 0u32;
277
278    fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
279        for (k, v) in table {
280            let full = if prefix.is_empty() {
281                k.clone()
282            } else {
283                format!("{prefix}.{k}")
284            };
285            match v {
286                toml::Value::Table(sub) => collect_keys(sub, &full, out),
287                toml::Value::Array(arr) => {
288                    out.push(full.clone());
289                    for item in arr {
290                        if let toml::Value::Table(sub) = item {
291                            for sk in sub.keys() {
292                                out.push(format!("{full}[].{sk}"));
293                            }
294                        }
295                    }
296                }
297                _ => out.push(full),
298            }
299        }
300    }
301
302    let mut user_keys = Vec::new();
303    collect_keys(&table, "", &mut user_keys);
304
305    for uk in &user_keys {
306        let base = uk.split("[].").next().unwrap_or(uk);
307        let field = uk.rsplit("[].").next().unwrap_or("");
308        let check_key = if uk.contains("[].") {
309            format!("{base}.{field}")
310        } else {
311            uk.clone()
312        };
313
314        if known.contains(&check_key)
315            || known
316                .iter()
317                .any(|k| check_key.starts_with(&format!("{k}.")))
318        {
319            validated += 1;
320        } else {
321            warnings += 1;
322            let suggestion = find_closest(&check_key, &known);
323            if let Some(sug) = suggestion {
324                eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
325            } else {
326                eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
327            }
328        }
329    }
330
331    let cfg = config::Config::load();
332    let budget = cfg.max_disk_mb_effective();
333    if budget > 0 {
334        let explicit_archive = cfg.archive.max_disk_mb;
335        let explicit_bm25 = cfg.bm25_max_cache_mb;
336        let sum = explicit_archive + explicit_bm25;
337        if sum > budget {
338            warnings += 1;
339            println!(
340                "  ⚠ max_disk_mb={budget} but archive.max_disk_mb({explicit_archive}) + bm25_max_cache_mb({explicit_bm25}) = {sum} exceeds budget"
341            );
342        }
343    }
344
345    let total = validated + warnings;
346    if warnings == 0 {
347        println!(
348            "[OK] All {total} keys validated successfully ({}).",
349            path.display()
350        );
351    } else {
352        println!(
353            "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
354            path.display()
355        );
356        std::process::exit(1);
357    }
358}
359
360fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
361    let mut best: Option<(usize, &str)> = None;
362    for candidate in haystack {
363        let d = levenshtein(needle, candidate);
364        if d <= 3 && (best.is_none() || d < best.unwrap().0) {
365            best = Some((d, candidate));
366        }
367    }
368    if best.is_some() {
369        return best.map(|(_, s)| s.to_string());
370    }
371    let leaf = needle.rsplit('.').next().unwrap_or(needle);
372    let mut leaf_best: Option<(usize, &str)> = None;
373    for candidate in haystack {
374        let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
375        let d = levenshtein(leaf, cand_leaf);
376        if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
377            leaf_best = Some((d, candidate));
378        }
379    }
380    leaf_best.map(|(_, s)| s.to_string())
381}
382
383fn levenshtein(a: &str, b: &str) -> usize {
384    let a: Vec<char> = a.chars().collect();
385    let b: Vec<char> = b.chars().collect();
386    let (m, n) = (a.len(), b.len());
387    let mut dp = vec![vec![0usize; n + 1]; m + 1];
388    for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
389        row[0] = i;
390    }
391    for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
392        *val = j;
393    }
394    for i in 1..=m {
395        for j in 1..=n {
396            let cost = usize::from(a[i - 1] != b[j - 1]);
397            dp[i][j] = (dp[i - 1][j] + 1)
398                .min(dp[i][j - 1] + 1)
399                .min(dp[i - 1][j - 1] + cost);
400        }
401    }
402    dp[m][n]
403}
404
405fn normalize_optional_upstream(value: &str) -> Option<String> {
406    use crate::core::config::normalize_url_opt;
407    let trimmed = value.trim();
408    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
409        None
410    } else {
411        normalize_url_opt(trimmed)
412    }
413}
414
415pub fn cmd_benchmark(args: &[String]) {
416    use crate::core::benchmark;
417    use crate::core::benchmark_compare;
418
419    let action = args.first().map_or("run", std::string::String::as_str);
420
421    match action {
422        "--help" | "-h" => {
423            println!("Usage: lean-ctx benchmark run [path] [--json]");
424            println!("       lean-ctx benchmark report [path]");
425            println!("       lean-ctx benchmark eval [path] [--json]");
426            println!("       lean-ctx benchmark compare [--repo path] [--output file.md]");
427            println!("       lean-ctx benchmark scorecard [--json] [--output file]");
428        }
429        "scorecard" => {
430            let is_json = args.iter().any(|a| a == "--json");
431            let output = parse_flag_value(args, "--output");
432            match crate::core::scorecard::run_scorecard() {
433                Ok(sc) => {
434                    let rendered = if is_json { sc.to_json() } else { sc.to_human() };
435                    if let Some(path) = output {
436                        if let Err(e) = std::fs::write(&path, &rendered) {
437                            eprintln!("Failed to write scorecard to {path}: {e}");
438                            std::process::exit(1);
439                        }
440                        eprintln!("Wrote scorecard to {path}");
441                    } else {
442                        print!("{rendered}");
443                    }
444                }
445                Err(e) => {
446                    eprintln!("Scorecard failed: {e}");
447                    std::process::exit(1);
448                }
449            }
450        }
451        "eval" => {
452            let path = args.get(1).map_or(".", std::string::String::as_str);
453            let is_json = args.iter().any(|a| a == "--json");
454            let root = std::path::Path::new(path);
455
456            let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
457            let cfg = crate::core::hybrid_search::HybridConfig::from_config();
458            let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
459
460            if queries.is_empty() {
461                eprintln!("No symbols found — cannot generate eval queries.");
462                std::process::exit(1);
463            }
464
465            let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
466            if is_json {
467                if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
468                    println!("{json}");
469                }
470            } else {
471                print!("{scorecard}");
472            }
473        }
474        "run" => {
475            let path = args.get(1).map_or(".", std::string::String::as_str);
476            let is_json = args.iter().any(|a| a == "--json");
477
478            let result = benchmark::run_project_benchmark(path);
479            if is_json {
480                println!("{}", benchmark::format_json(&result));
481            } else {
482                println!("{}", benchmark::format_terminal(&result));
483            }
484        }
485        "report" => {
486            let path = args.get(1).map_or(".", std::string::String::as_str);
487            let result = benchmark::run_project_benchmark(path);
488            println!("{}", benchmark::format_markdown(&result));
489        }
490        "compare" => {
491            let repo = parse_flag_value(args, "--repo").unwrap_or_else(|| ".".to_string());
492            let output = parse_flag_value(args, "--output");
493
494            let root = std::path::Path::new(&repo);
495            if !root.exists() {
496                eprintln!("Repository path does not exist: {repo}");
497                std::process::exit(1);
498            }
499
500            let report = benchmark_compare::run_compare(root, output.as_deref());
501
502            println!("{}", benchmark_compare::report::generate_terminal(&report));
503
504            if output.is_none() {
505                eprintln!("Tip: use --output BENCHMARKS.md to save the full markdown report");
506            }
507        }
508        _ => {
509            if std::path::Path::new(action).exists() {
510                let result = benchmark::run_project_benchmark(action);
511                println!("{}", benchmark::format_terminal(&result));
512            } else {
513                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
514                eprintln!("       lean-ctx benchmark report [path]");
515                eprintln!("       lean-ctx benchmark eval [path] [--json]");
516                eprintln!("       lean-ctx benchmark compare [--repo path] [--output file.md]");
517                eprintln!("       lean-ctx benchmark scorecard [--json] [--output file]");
518                std::process::exit(1);
519            }
520        }
521    }
522}
523
524fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
525    args.iter()
526        .position(|a| a == flag)
527        .and_then(|i| args.get(i + 1))
528        .cloned()
529}
530
531pub fn cmd_stats(args: &[String]) {
532    match args.first().map(std::string::String::as_str) {
533        Some("reset-cep") => {
534            crate::core::stats::reset_cep();
535            println!("CEP stats reset. Shell hook data preserved.");
536        }
537        Some("json") => {
538            let store = crate::core::stats::load();
539            println!(
540                "{}",
541                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
542            );
543        }
544        _ => {
545            let store = crate::core::stats::load();
546            let input_saved = store
547                .total_input_tokens
548                .saturating_sub(store.total_output_tokens);
549            let pct = if store.total_input_tokens > 0 {
550                input_saved as f64 / store.total_input_tokens as f64 * 100.0
551            } else {
552                0.0
553            };
554            println!("Commands:    {}", store.total_commands);
555            println!("Input:       {} tokens", store.total_input_tokens);
556            println!("Output:      {} tokens", store.total_output_tokens);
557            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
558            println!();
559            println!("CEP sessions:  {}", store.cep.sessions);
560            println!(
561                "CEP tokens:    {} → {}",
562                store.cep.total_tokens_original, store.cep.total_tokens_compressed
563            );
564            println!();
565            println!("Subcommands: stats reset-cep | stats json");
566        }
567    }
568}
569
570pub fn cmd_cache(args: &[String]) {
571    use crate::core::cli_cache;
572    match args.first().map(std::string::String::as_str) {
573        Some("clear") => {
574            let count = cli_cache::clear();
575            println!("Cleared {count} cached entries.");
576        }
577        Some("reset") => {
578            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
579            if project_flag {
580                let root =
581                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
582                if let Some(root) = root {
583                    let count = cli_cache::clear_project(&root);
584                    println!("Reset {count} cache entries for project: {root}");
585                } else {
586                    eprintln!("No active project root found. Start a session first.");
587                    std::process::exit(1);
588                }
589            } else {
590                let count = cli_cache::clear();
591                println!("Reset all {count} cache entries.");
592            }
593        }
594        Some("stats") => {
595            let (hits, reads, entries) = cli_cache::stats();
596            let rate = if reads > 0 {
597                (hits as f64 / reads as f64 * 100.0).round() as u32
598            } else {
599                0
600            };
601            println!("CLI Cache Stats (lean-ctx read / lean-ctx grep):");
602            println!("  Entries:   {entries}");
603            println!("  Reads:     {reads}");
604            println!("  Hits:      {hits}");
605            println!("  Hit Rate:  {rate}%");
606
607            if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
608                let live_path = dir.join("mcp-live.json");
609                if let Ok(content) = std::fs::read_to_string(&live_path) {
610                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
611                        let mcp_reads = val
612                            .get("total_reads")
613                            .and_then(serde_json::Value::as_u64)
614                            .unwrap_or(0);
615                        let mcp_hits = val
616                            .get("cache_hits")
617                            .and_then(serde_json::Value::as_u64)
618                            .unwrap_or(0);
619                        let mcp_saved = val
620                            .get("tokens_saved")
621                            .and_then(serde_json::Value::as_u64)
622                            .unwrap_or(0);
623                        let mcp_rate = if mcp_reads > 0 {
624                            (mcp_hits as f64 / mcp_reads as f64 * 100.0).round() as u32
625                        } else {
626                            0
627                        };
628                        let updated = val
629                            .get("updated_at")
630                            .and_then(serde_json::Value::as_str)
631                            .unwrap_or("unknown");
632                        println!();
633                        println!("MCP Session Cache (ctx_read via AI editor):");
634                        println!("  Reads:         {mcp_reads}");
635                        println!("  Hits:          {mcp_hits}");
636                        println!("  Hit Rate:      {mcp_rate}%");
637                        println!("  Tokens Saved:  {mcp_saved}");
638                        println!("  Last Updated:  {updated}");
639                    }
640                } else {
641                    println!();
642                    println!(
643                        "MCP Session Cache: no data yet (start a session with your AI editor)"
644                    );
645                }
646            }
647        }
648        Some("invalidate") => {
649            if args.len() < 2 {
650                eprintln!("Usage: lean-ctx cache invalidate <path>");
651                std::process::exit(1);
652            }
653            cli_cache::invalidate(&args[1]);
654            println!("Invalidated cache for {}", args[1]);
655        }
656        Some("prune") => {
657            let bm25 = prune_bm25_caches();
658            let graph = prune_graph_caches();
659            let removed = bm25.removed + graph.removed;
660            let freed = bm25.bytes_freed + graph.bytes_freed;
661            println!(
662                "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {})",
663                removed,
664                freed as f64 / 1_048_576.0,
665                bm25.removed,
666                graph.removed,
667            );
668        }
669        _ => {
670            let (hits, reads, entries) = cli_cache::stats();
671            let rate = if reads > 0 {
672                (hits as f64 / reads as f64 * 100.0).round() as u32
673            } else {
674                0
675            };
676            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
677            println!();
678            println!("Subcommands:");
679            println!("  cache stats       Show detailed stats");
680            println!("  cache clear       Clear all cached entries");
681            println!("  cache reset       Reset all cache (or --project for current project only)");
682            println!("  cache invalidate  Remove specific file from cache");
683            println!(
684                "  cache prune       Remove oversized, quarantined, and orphaned indexes (BM25 + graphs)"
685            );
686        }
687    }
688}
689
690pub struct PruneResult {
691    pub scanned: u32,
692    pub removed: u32,
693    pub bytes_freed: u64,
694}
695
696pub fn prune_bm25_caches() -> PruneResult {
697    let mut result = PruneResult {
698        scanned: 0,
699        removed: 0,
700        bytes_freed: 0,
701    };
702
703    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
704        return result;
705    };
706    let vectors_dir = data_dir.join("vectors");
707    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
708        return result;
709    };
710
711    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
712
713    for entry in entries.flatten() {
714        let dir = entry.path();
715        if !dir.is_dir() {
716            continue;
717        }
718        result.scanned += 1;
719
720        for q_name in &[
721            "bm25_index.json.quarantined",
722            "bm25_index.bin.quarantined",
723            "bm25_index.bin.zst.quarantined",
724        ] {
725            let quarantined = dir.join(q_name);
726            if quarantined.exists() {
727                if let Ok(meta) = std::fs::metadata(&quarantined) {
728                    result.bytes_freed += meta.len();
729                }
730                let _ = std::fs::remove_file(&quarantined);
731                result.removed += 1;
732                println!("  Removed quarantined: {}", quarantined.display());
733            }
734        }
735
736        let index_path = if dir.join("bm25_index.bin.zst").exists() {
737            dir.join("bm25_index.bin.zst")
738        } else if dir.join("bm25_index.bin").exists() {
739            dir.join("bm25_index.bin")
740        } else {
741            dir.join("bm25_index.json")
742        };
743        if let Ok(meta) = std::fs::metadata(&index_path) {
744            if meta.len() > max_bytes {
745                result.bytes_freed += meta.len();
746                let _ = std::fs::remove_file(&index_path);
747                result.removed += 1;
748                println!(
749                    "  Removed oversized ({:.1} MB): {}",
750                    meta.len() as f64 / 1_048_576.0,
751                    index_path.display()
752                );
753            }
754        }
755
756        let marker = dir.join("project_root.txt");
757        if let Ok(root_str) = std::fs::read_to_string(&marker) {
758            let root_path = std::path::Path::new(root_str.trim());
759            if !root_path.exists() {
760                let freed = dir_size(&dir);
761                result.bytes_freed += freed;
762                let _ = std::fs::remove_dir_all(&dir);
763                result.removed += 1;
764                println!(
765                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
766                    freed as f64 / 1_048_576.0,
767                    root_str.trim(),
768                    dir.display()
769                );
770            }
771        }
772    }
773
774    result
775}
776
777pub fn prune_graph_caches() -> PruneResult {
778    let mut result = PruneResult {
779        scanned: 0,
780        removed: 0,
781        bytes_freed: 0,
782    };
783
784    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
785        return result;
786    };
787    let graphs_dir = data_dir.join("graphs");
788    let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
789        return result;
790    };
791
792    for entry in entries.flatten() {
793        let dir = entry.path();
794        if !dir.is_dir() {
795            continue;
796        }
797        result.scanned += 1;
798
799        let index_path = dir.join("index.json.zst");
800        let index_json = dir.join("index.json");
801
802        let has_index = index_path.exists() || index_json.exists();
803        if !has_index {
804            continue;
805        }
806
807        let idx_file = if index_path.exists() {
808            &index_path
809        } else {
810            &index_json
811        };
812
813        let root_from_index = try_read_project_root_from_graph(idx_file);
814        if let Some(root) = root_from_index {
815            if !root.is_empty() && !std::path::Path::new(&root).exists() {
816                let freed = dir_size(&dir);
817                result.bytes_freed += freed;
818                let _ = std::fs::remove_dir_all(&dir);
819                result.removed += 1;
820                println!(
821                    "  Removed orphaned graph ({:.1} MB, project gone: {}): {}",
822                    freed as f64 / 1_048_576.0,
823                    root,
824                    dir.display()
825                );
826                continue;
827            }
828        }
829
830        if let Ok(meta) = std::fs::metadata(idx_file) {
831            if meta.len() > 100 * 1024 * 1024 {
832                result.bytes_freed += meta.len();
833                let _ = std::fs::remove_file(idx_file);
834                result.removed += 1;
835                println!(
836                    "  Removed oversized graph ({:.1} MB): {}",
837                    meta.len() as f64 / 1_048_576.0,
838                    idx_file.display()
839                );
840            }
841        }
842    }
843
844    result
845}
846
847fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
848    let data = if path.extension().and_then(|e| e.to_str()) == Some("zst") {
849        let compressed = std::fs::read(path).ok()?;
850        zstd::decode_all(compressed.as_slice()).ok()?
851    } else {
852        std::fs::read(path).ok()?
853    };
854    let content = String::from_utf8(data).ok()?;
855    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
856    val.get("project_root")?.as_str().map(String::from)
857}
858
859pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
860# Full reference: https://leanctx.com/docs/configuration
861# For all settings: lean-ctx config init --full
862
863# ── High-Level Knobs ─────────────────────────────────────────────────
864# These auto-adjust advanced settings. Override individual values below
865# only if you need fine-grained control.
866
867# Output style for the model's prose (not tool-output compression):
868#   off    — no style guidance
869#   lite   — plain-English concise (default; readable, still token-saving)
870#   standard / max — denser symbolic "power modes" (opt-in)
871compression_level = "lite"
872
873# RAM/feature trade-off: low | balanced | performance
874memory_profile = "balanced"
875
876# Maximum % of system RAM lean-ctx may use (1-50)
877max_ram_percent = 5
878
879# Total disk budget in MB (0 = use individual limits).
880# Distributes proportionally: archive ~25%, BM25 cache ~10%.
881# max_disk_mb = 2000
882
883# Auto-purge data older than N days (0 = disabled).
884# Flows into archive.max_age_hours.
885# max_staleness_days = 30
886
887# Explicit project paths to scan/index (default: auto-detect).
888# [ide_paths]
889# cursor = ["/home/user/projects/app1"]
890
891# ── Proxy ────────────────────────────────────────────────────────────
892# proxy_enabled = false
893# proxy_port = 3128
894"#;
895
896fn write_simplified_config() -> Result<String, String> {
897    let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
898    if let Some(dir) = path.parent() {
899        std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
900    }
901    std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
902    Ok(path.to_string_lossy().to_string())
903}
904
905fn cmd_show_effective() {
906    let cfg = config::Config::load();
907    let compression = config::CompressionLevel::effective(&cfg);
908    let policy = cfg.memory_policy_effective().unwrap_or_default();
909
910    println!("╭─── Simplified (high-level) ───────────────────────────────╮");
911    println!(
912        "│ compression_level   = {:10}  {}",
913        format!("{compression:?}"),
914        source_hint(
915            "LEAN_CTX_COMPRESSION",
916            cfg.compression_level != config::CompressionLevel::Off
917        )
918    );
919    println!(
920        "│ max_disk_mb         = {:10}  {}",
921        cfg.max_disk_mb_effective(),
922        source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
923    );
924    println!(
925        "│ max_ram_percent     = {:10}  {}",
926        cfg.max_ram_percent,
927        source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
928    );
929    println!(
930        "│ max_staleness_days  = {:10}  {}",
931        cfg.max_staleness_days_effective(),
932        source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
933    );
934    println!(
935        "│ memory_profile      = {:10}  {}",
936        format!("{:?}", cfg.memory_profile),
937        source_hint("LEAN_CTX_MEMORY_PROFILE", false)
938    );
939    println!("╰────────────────────────────────────────────────────────────╯");
940
941    println!();
942    println!("╭─── Derived effective limits ────────────────────────────────╮");
943    println!(
944        "│ archive_max_disk_mb    = {:>6} MB",
945        cfg.archive_max_disk_mb_effective()
946    );
947    println!(
948        "│ bm25_max_cache_mb      = {:>6} MB",
949        cfg.bm25_max_cache_mb_effective()
950    );
951    println!(
952        "│ archive_max_age_hours  = {:>6} h",
953        cfg.archive_max_age_hours_effective()
954    );
955    println!(
956        "│ graph_index_max_files  = {:>6}",
957        cfg.graph_index_max_files
958    );
959    println!("│");
960    println!(
961        "│ memory.knowledge.max_facts     = {:>6}",
962        policy.knowledge.max_facts
963    );
964    println!(
965        "│ memory.knowledge.max_patterns  = {:>6}",
966        policy.knowledge.max_patterns
967    );
968    println!(
969        "│ memory.episodic.max_episodes   = {:>6}",
970        policy.episodic.max_episodes
971    );
972    println!(
973        "│ memory.procedural.max_procedures = {:>4}",
974        policy.procedural.max_procedures
975    );
976    println!("╰────────────────────────────────────────────────────────────╯");
977
978    if cfg.max_disk_mb_effective() > 0 {
979        println!();
980        println!(
981            "  ℹ  max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
982            cfg.max_disk_mb_effective(),
983            (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
984        );
985    }
986}
987
988fn source_hint(env_var: &str, config_set: bool) -> &'static str {
989    if std::env::var(env_var).is_ok() {
990        "← env"
991    } else if config_set {
992        "← config"
993    } else {
994        "← default"
995    }
996}
997
998fn dir_size(path: &std::path::Path) -> u64 {
999    let mut total = 0u64;
1000    if let Ok(entries) = std::fs::read_dir(path) {
1001        for entry in entries.flatten() {
1002            let p = entry.path();
1003            if p.is_file() {
1004                total += std::fs::metadata(&p).map_or(0, |m| m.len());
1005            } else if p.is_dir() {
1006                total += dir_size(&p);
1007            }
1008        }
1009    }
1010    total
1011}