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