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
405    let action = args.first().map_or("run", std::string::String::as_str);
406
407    match action {
408        "--help" | "-h" => {
409            println!("Usage: lean-ctx benchmark run [path] [--json]");
410            println!("       lean-ctx benchmark report [path]");
411            println!("       lean-ctx benchmark eval [path] [--json]");
412        }
413        "eval" => {
414            let path = args.get(1).map_or(".", std::string::String::as_str);
415            let is_json = args.iter().any(|a| a == "--json");
416            let root = std::path::Path::new(path);
417
418            let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
419            let cfg = crate::core::hybrid_search::HybridConfig::from_config();
420            let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
421
422            if queries.is_empty() {
423                eprintln!("No symbols found — cannot generate eval queries.");
424                std::process::exit(1);
425            }
426
427            let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
428            if is_json {
429                if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
430                    println!("{json}");
431                }
432            } else {
433                print!("{scorecard}");
434            }
435        }
436        "run" => {
437            let path = args.get(1).map_or(".", std::string::String::as_str);
438            let is_json = args.iter().any(|a| a == "--json");
439
440            let result = benchmark::run_project_benchmark(path);
441            if is_json {
442                println!("{}", benchmark::format_json(&result));
443            } else {
444                println!("{}", benchmark::format_terminal(&result));
445            }
446        }
447        "report" => {
448            let path = args.get(1).map_or(".", std::string::String::as_str);
449            let result = benchmark::run_project_benchmark(path);
450            println!("{}", benchmark::format_markdown(&result));
451        }
452        _ => {
453            if std::path::Path::new(action).exists() {
454                let result = benchmark::run_project_benchmark(action);
455                println!("{}", benchmark::format_terminal(&result));
456            } else {
457                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
458                eprintln!("       lean-ctx benchmark report [path]");
459                std::process::exit(1);
460            }
461        }
462    }
463}
464
465pub fn cmd_stats(args: &[String]) {
466    match args.first().map(std::string::String::as_str) {
467        Some("reset-cep") => {
468            crate::core::stats::reset_cep();
469            println!("CEP stats reset. Shell hook data preserved.");
470        }
471        Some("json") => {
472            let store = crate::core::stats::load();
473            println!(
474                "{}",
475                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
476            );
477        }
478        _ => {
479            let store = crate::core::stats::load();
480            let input_saved = store
481                .total_input_tokens
482                .saturating_sub(store.total_output_tokens);
483            let pct = if store.total_input_tokens > 0 {
484                input_saved as f64 / store.total_input_tokens as f64 * 100.0
485            } else {
486                0.0
487            };
488            println!("Commands:    {}", store.total_commands);
489            println!("Input:       {} tokens", store.total_input_tokens);
490            println!("Output:      {} tokens", store.total_output_tokens);
491            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
492            println!();
493            println!("CEP sessions:  {}", store.cep.sessions);
494            println!(
495                "CEP tokens:    {} → {}",
496                store.cep.total_tokens_original, store.cep.total_tokens_compressed
497            );
498            println!();
499            println!("Subcommands: stats reset-cep | stats json");
500        }
501    }
502}
503
504pub fn cmd_cache(args: &[String]) {
505    use crate::core::cli_cache;
506    match args.first().map(std::string::String::as_str) {
507        Some("clear") => {
508            let count = cli_cache::clear();
509            println!("Cleared {count} cached entries.");
510        }
511        Some("reset") => {
512            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
513            if project_flag {
514                let root =
515                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
516                if let Some(root) = root {
517                    let count = cli_cache::clear_project(&root);
518                    println!("Reset {count} cache entries for project: {root}");
519                } else {
520                    eprintln!("No active project root found. Start a session first.");
521                    std::process::exit(1);
522                }
523            } else {
524                let count = cli_cache::clear();
525                println!("Reset all {count} cache entries.");
526            }
527        }
528        Some("stats") => {
529            let (hits, reads, entries) = cli_cache::stats();
530            let rate = if reads > 0 {
531                (hits as f64 / reads as f64 * 100.0).round() as u32
532            } else {
533                0
534            };
535            println!("CLI Cache Stats:");
536            println!("  Entries:   {entries}");
537            println!("  Reads:     {reads}");
538            println!("  Hits:      {hits}");
539            println!("  Hit Rate:  {rate}%");
540        }
541        Some("invalidate") => {
542            if args.len() < 2 {
543                eprintln!("Usage: lean-ctx cache invalidate <path>");
544                std::process::exit(1);
545            }
546            cli_cache::invalidate(&args[1]);
547            println!("Invalidated cache for {}", args[1]);
548        }
549        Some("prune") => {
550            let bm25 = prune_bm25_caches();
551            let graph = prune_graph_caches();
552            let removed = bm25.removed + graph.removed;
553            let freed = bm25.bytes_freed + graph.bytes_freed;
554            println!(
555                "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {})",
556                removed,
557                freed as f64 / 1_048_576.0,
558                bm25.removed,
559                graph.removed,
560            );
561        }
562        _ => {
563            let (hits, reads, entries) = cli_cache::stats();
564            let rate = if reads > 0 {
565                (hits as f64 / reads as f64 * 100.0).round() as u32
566            } else {
567                0
568            };
569            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
570            println!();
571            println!("Subcommands:");
572            println!("  cache stats       Show detailed stats");
573            println!("  cache clear       Clear all cached entries");
574            println!("  cache reset       Reset all cache (or --project for current project only)");
575            println!("  cache invalidate  Remove specific file from cache");
576            println!(
577                "  cache prune       Remove oversized, quarantined, and orphaned indexes (BM25 + graphs)"
578            );
579        }
580    }
581}
582
583pub struct PruneResult {
584    pub scanned: u32,
585    pub removed: u32,
586    pub bytes_freed: u64,
587}
588
589pub fn prune_bm25_caches() -> PruneResult {
590    let mut result = PruneResult {
591        scanned: 0,
592        removed: 0,
593        bytes_freed: 0,
594    };
595
596    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
597        return result;
598    };
599    let vectors_dir = data_dir.join("vectors");
600    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
601        return result;
602    };
603
604    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
605
606    for entry in entries.flatten() {
607        let dir = entry.path();
608        if !dir.is_dir() {
609            continue;
610        }
611        result.scanned += 1;
612
613        for q_name in &[
614            "bm25_index.json.quarantined",
615            "bm25_index.bin.quarantined",
616            "bm25_index.bin.zst.quarantined",
617        ] {
618            let quarantined = dir.join(q_name);
619            if quarantined.exists() {
620                if let Ok(meta) = std::fs::metadata(&quarantined) {
621                    result.bytes_freed += meta.len();
622                }
623                let _ = std::fs::remove_file(&quarantined);
624                result.removed += 1;
625                println!("  Removed quarantined: {}", quarantined.display());
626            }
627        }
628
629        let index_path = if dir.join("bm25_index.bin.zst").exists() {
630            dir.join("bm25_index.bin.zst")
631        } else if dir.join("bm25_index.bin").exists() {
632            dir.join("bm25_index.bin")
633        } else {
634            dir.join("bm25_index.json")
635        };
636        if let Ok(meta) = std::fs::metadata(&index_path) {
637            if meta.len() > max_bytes {
638                result.bytes_freed += meta.len();
639                let _ = std::fs::remove_file(&index_path);
640                result.removed += 1;
641                println!(
642                    "  Removed oversized ({:.1} MB): {}",
643                    meta.len() as f64 / 1_048_576.0,
644                    index_path.display()
645                );
646            }
647        }
648
649        let marker = dir.join("project_root.txt");
650        if let Ok(root_str) = std::fs::read_to_string(&marker) {
651            let root_path = std::path::Path::new(root_str.trim());
652            if !root_path.exists() {
653                let freed = dir_size(&dir);
654                result.bytes_freed += freed;
655                let _ = std::fs::remove_dir_all(&dir);
656                result.removed += 1;
657                println!(
658                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
659                    freed as f64 / 1_048_576.0,
660                    root_str.trim(),
661                    dir.display()
662                );
663            }
664        }
665    }
666
667    result
668}
669
670pub fn prune_graph_caches() -> PruneResult {
671    let mut result = PruneResult {
672        scanned: 0,
673        removed: 0,
674        bytes_freed: 0,
675    };
676
677    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
678        return result;
679    };
680    let graphs_dir = data_dir.join("graphs");
681    let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
682        return result;
683    };
684
685    for entry in entries.flatten() {
686        let dir = entry.path();
687        if !dir.is_dir() {
688            continue;
689        }
690        result.scanned += 1;
691
692        let index_path = dir.join("index.json.zst");
693        let index_json = dir.join("index.json");
694
695        let has_index = index_path.exists() || index_json.exists();
696        if !has_index {
697            continue;
698        }
699
700        let idx_file = if index_path.exists() {
701            &index_path
702        } else {
703            &index_json
704        };
705
706        let root_from_index = try_read_project_root_from_graph(idx_file);
707        if let Some(root) = root_from_index {
708            if !root.is_empty() && !std::path::Path::new(&root).exists() {
709                let freed = dir_size(&dir);
710                result.bytes_freed += freed;
711                let _ = std::fs::remove_dir_all(&dir);
712                result.removed += 1;
713                println!(
714                    "  Removed orphaned graph ({:.1} MB, project gone: {}): {}",
715                    freed as f64 / 1_048_576.0,
716                    root,
717                    dir.display()
718                );
719                continue;
720            }
721        }
722
723        if let Ok(meta) = std::fs::metadata(idx_file) {
724            if meta.len() > 100 * 1024 * 1024 {
725                result.bytes_freed += meta.len();
726                let _ = std::fs::remove_file(idx_file);
727                result.removed += 1;
728                println!(
729                    "  Removed oversized graph ({:.1} MB): {}",
730                    meta.len() as f64 / 1_048_576.0,
731                    idx_file.display()
732                );
733            }
734        }
735    }
736
737    result
738}
739
740fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
741    let data = if path.extension().and_then(|e| e.to_str()) == Some("zst") {
742        let compressed = std::fs::read(path).ok()?;
743        zstd::decode_all(compressed.as_slice()).ok()?
744    } else {
745        std::fs::read(path).ok()?
746    };
747    let content = String::from_utf8(data).ok()?;
748    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
749    val.get("project_root")?.as_str().map(String::from)
750}
751
752pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
753# Full reference: https://leanctx.com/docs/configuration
754# For all settings: lean-ctx config init --full
755
756# ── High-Level Knobs ─────────────────────────────────────────────────
757# These auto-adjust advanced settings. Override individual values below
758# only if you need fine-grained control.
759
760# Output style for the model's prose (not tool-output compression):
761#   off    — no style guidance
762#   lite   — plain-English concise (default; readable, still token-saving)
763#   standard / max — denser symbolic "power modes" (opt-in)
764compression_level = "lite"
765
766# RAM/feature trade-off: low | balanced | performance
767memory_profile = "balanced"
768
769# Maximum % of system RAM lean-ctx may use (1-50)
770max_ram_percent = 5
771
772# Total disk budget in MB (0 = use individual limits).
773# Distributes proportionally: archive ~25%, BM25 cache ~10%.
774# max_disk_mb = 2000
775
776# Auto-purge data older than N days (0 = disabled).
777# Flows into archive.max_age_hours.
778# max_staleness_days = 30
779
780# Explicit project paths to scan/index (default: auto-detect).
781# [ide_paths]
782# cursor = ["/home/user/projects/app1"]
783
784# ── Proxy ────────────────────────────────────────────────────────────
785# proxy_enabled = false
786# proxy_port = 3128
787"#;
788
789fn write_simplified_config() -> Result<String, String> {
790    let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
791    if let Some(dir) = path.parent() {
792        std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
793    }
794    std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
795    Ok(path.to_string_lossy().to_string())
796}
797
798fn cmd_show_effective() {
799    let cfg = config::Config::load();
800    let compression = config::CompressionLevel::effective(&cfg);
801    let policy = cfg.memory_policy_effective().unwrap_or_default();
802
803    println!("╭─── Simplified (high-level) ───────────────────────────────╮");
804    println!(
805        "│ compression_level   = {:10}  {}",
806        format!("{compression:?}"),
807        source_hint(
808            "LEAN_CTX_COMPRESSION",
809            cfg.compression_level != config::CompressionLevel::Off
810        )
811    );
812    println!(
813        "│ max_disk_mb         = {:10}  {}",
814        cfg.max_disk_mb_effective(),
815        source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
816    );
817    println!(
818        "│ max_ram_percent     = {:10}  {}",
819        cfg.max_ram_percent,
820        source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
821    );
822    println!(
823        "│ max_staleness_days  = {:10}  {}",
824        cfg.max_staleness_days_effective(),
825        source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
826    );
827    println!(
828        "│ memory_profile      = {:10}  {}",
829        format!("{:?}", cfg.memory_profile),
830        source_hint("LEAN_CTX_MEMORY_PROFILE", false)
831    );
832    println!("╰────────────────────────────────────────────────────────────╯");
833
834    println!();
835    println!("╭─── Derived effective limits ────────────────────────────────╮");
836    println!(
837        "│ archive_max_disk_mb    = {:>6} MB",
838        cfg.archive_max_disk_mb_effective()
839    );
840    println!(
841        "│ bm25_max_cache_mb      = {:>6} MB",
842        cfg.bm25_max_cache_mb_effective()
843    );
844    println!(
845        "│ archive_max_age_hours  = {:>6} h",
846        cfg.archive_max_age_hours_effective()
847    );
848    println!(
849        "│ graph_index_max_files  = {:>6}",
850        cfg.graph_index_max_files
851    );
852    println!("│");
853    println!(
854        "│ memory.knowledge.max_facts     = {:>6}",
855        policy.knowledge.max_facts
856    );
857    println!(
858        "│ memory.knowledge.max_patterns  = {:>6}",
859        policy.knowledge.max_patterns
860    );
861    println!(
862        "│ memory.episodic.max_episodes   = {:>6}",
863        policy.episodic.max_episodes
864    );
865    println!(
866        "│ memory.procedural.max_procedures = {:>4}",
867        policy.procedural.max_procedures
868    );
869    println!("╰────────────────────────────────────────────────────────────╯");
870
871    if cfg.max_disk_mb_effective() > 0 {
872        println!();
873        println!(
874            "  ℹ  max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
875            cfg.max_disk_mb_effective(),
876            (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
877        );
878    }
879}
880
881fn source_hint(env_var: &str, config_set: bool) -> &'static str {
882    if std::env::var(env_var).is_ok() {
883        "← env"
884    } else if config_set {
885        "← config"
886    } else {
887        "← default"
888    }
889}
890
891fn dir_size(path: &std::path::Path) -> u64 {
892    let mut total = 0u64;
893    if let Ok(entries) = std::fs::read_dir(path) {
894        for entry in entries.flatten() {
895            let p = entry.path();
896            if p.is_file() {
897                total += std::fs::metadata(&p).map_or(0, |m| m.len());
898            } else if p.is_dir() {
899                total += dir_size(&p);
900            }
901        }
902    }
903    total
904}