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