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 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 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 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 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 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 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}