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