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 init_full_config();
20 } else {
21 match write_simplified_config() {
22 Ok(path) => println!("Created simplified config at {path}"),
23 Err(e) => eprintln!("Error: {e}"),
24 }
25 }
26 }
27 "set" => {
28 if args.len() < 3 {
29 eprintln!("Usage: lean-ctx config set <key> <value>");
30 std::process::exit(1);
31 }
32 let key = &args[1];
33 let val = &args[2];
34
35 let (write_key, write_val): (String, String) = match key.as_str() {
41 "theme" if theme::from_preset(val).is_none() && val != "custom" => {
42 eprintln!(
43 "Unknown theme '{val}'. Available: {}",
44 theme::PRESET_NAMES.join(", ")
45 );
46 std::process::exit(1);
47 }
48 "tee_on_error" | "tee_mode" => {
49 let normalized = match val.as_str() {
50 "true" => "failures",
51 "false" => "never",
52 other => other,
53 };
54 ("tee_mode".to_string(), normalized.to_string())
55 }
56 "project_root" => {
57 let path = std::path::Path::new(val.as_str());
58 if !path.exists() || !path.is_dir() {
59 eprintln!("Error: '{val}' is not an existing directory.");
60 std::process::exit(1);
61 }
62 (key.clone(), val.clone())
63 }
64 "embedding.model"
65 if crate::core::embeddings::model_registry::EmbeddingModel::from_str_name(
66 val,
67 )
68 .is_none() =>
69 {
70 eprintln!(
71 "Unknown embedding model '{val}'. Available: minilm (default), \
72 nomic — or hf:org/repo[@revision] for any HuggingFace repo with an \
73 ONNX export, e.g. hf:jinaai/jina-embeddings-v2-base-code for code \
74 (see docs/guides/custom-embeddings.md)."
75 );
76 std::process::exit(1);
77 }
78 "proxy.anthropic_upstream"
79 | "proxy.openai_upstream"
80 | "proxy.chatgpt_upstream"
81 | "proxy.gemini_upstream" => {
82 let effective = normalize_optional_upstream(val).unwrap_or_default();
83 (key.clone(), effective)
84 }
85 _ => (key.clone(), val.clone()),
86 };
87
88 write_config_key(&write_key, &write_val, key, val, args);
89 }
90 "schema" => {
91 let schema = config::schema::ConfigSchema::generate();
92 println!(
93 "{}",
94 serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
95 );
96 }
97 "validate" => {
98 cmd_validate();
99 }
100 "show" | "effective" => {
101 cmd_show_effective();
102 }
103 "path" | "which" => {
104 cmd_config_path();
105 }
106 "apply" | "reload" => {
107 cmd_apply();
108 }
109 _ => {
110 eprintln!("Usage: lean-ctx config [init|set|show|schema|validate|apply|path]");
111 std::process::exit(1);
112 }
113 }
114}
115
116fn write_config_key(key: &str, value: &str, display_key: &str, display_val: &str, args: &[String]) {
126 const BOLD: &str = "\x1b[1m";
127 const DIM: &str = "\x1b[2m";
128 const YELLOW: &str = "\x1b[33m";
129 const RST: &str = "\x1b[0m";
130
131 let current = config::setter::current_value(key);
132
133 if current.as_deref() == Some(value) {
134 println!("{display_key} is already set to {display_val} — unchanged.");
135 return;
136 }
137
138 if let Some(risk) = config::risk::classify(key) {
139 let before = current.as_deref().unwrap_or("(default)");
140 let after = if value.is_empty() { "(default)" } else { value };
141 println!("{BOLD}Review change to {display_key}{RST}");
142 println!(" {before} → {after}");
143 println!(" {YELLOW}{}{RST}", risk.note);
144 if !super::prompt::confirm(
145 &format!("Apply {display_key} = {display_val}?"),
146 super::prompt::wants_yes(args),
147 ) {
148 println!("{DIM}Aborted — {display_key} left unchanged.{RST}");
149 return;
150 }
151 }
152
153 match config::setter::set_by_key(key, value) {
154 Ok(_) => println!("Updated {display_key} = {display_val}"),
155 Err(e) => {
156 eprintln!("{e}");
157 std::process::exit(1);
158 }
159 }
160}
161
162fn config_for_full_init(existing_raw: Option<&str>) -> Result<config::Config, String> {
176 match existing_raw.map(str::trim).filter(|raw| !raw.is_empty()) {
177 Some(raw) => toml::from_str::<config::Config>(raw).map_err(|e| e.to_string()),
178 None => Ok(config::Config::default()),
179 }
180}
181
182fn init_full_config() {
190 let Some(path) = config::Config::path() else {
191 eprintln!("Error: cannot determine the config path");
192 return;
193 };
194
195 let existing_raw = std::fs::read_to_string(&path).ok();
196
197 let cfg = match config_for_full_init(existing_raw.as_deref()) {
198 Ok(cfg) => cfg,
199 Err(e) => {
200 eprintln!(
201 "Error: refusing to overwrite an unparseable config.toml ({e}).\n \
202 Fix it manually or run `lean-ctx doctor --fix`, then retry."
203 );
204 return;
205 }
206 };
207
208 let schema = config::schema::ConfigSchema::generate();
209 let rendered = config::render_annotated_config(&cfg, &schema);
210
211 match crate::config_io::write_atomic_with_backup(&path, &rendered) {
212 Ok(()) => println!("Created full annotated config at {}", path.display()),
213 Err(e) => eprintln!("Error: {e}"),
214 }
215}
216
217fn cmd_apply() {
218 use crate::daemon;
219 use crate::ipc;
220
221 println!("Applying config changes…");
222
223 println!("\n[1/4] Validating config…");
225 let schema = config::schema::ConfigSchema::generate();
226 let known = schema.known_keys();
227 let cfg = config::Config::load();
228
229 if let Some(path) = config::Config::path()
230 && path.exists()
231 && let Ok(raw) = std::fs::read_to_string(&path)
232 && let Ok(table) = raw.parse::<toml::Table>()
233 {
234 let mut user_keys = Vec::new();
235 fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
236 for (k, v) in table {
237 let full = if prefix.is_empty() {
238 k.clone()
239 } else {
240 format!("{prefix}.{k}")
241 };
242 if let toml::Value::Table(sub) = v {
243 collect_flat(sub, &full, out);
244 } else {
245 out.push(full);
246 }
247 }
248 }
249 collect_flat(&table, "", &mut user_keys);
250 let warnings: Vec<_> = user_keys
251 .iter()
252 .filter(|uk| {
253 !known.contains(uk) && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
254 })
255 .collect();
256 if warnings.is_empty() {
257 println!(" ✓ All config keys valid.");
258 } else {
259 for w in &warnings {
260 eprintln!(" [WARN] Unknown key: {w}");
261 }
262 eprintln!(
263 " {} unknown key(s) found. Continuing anyway…",
264 warnings.len()
265 );
266 }
267 }
268
269 println!("\n[2/4] Restarting processes…");
271 crate::proxy_autostart::stop();
272
273 if let Err(e) = daemon::stop_daemon() {
274 eprintln!(" Warning: daemon stop: {e}");
275 }
276
277 let orphans = ipc::process::kill_all_by_name("lean-ctx");
278 if orphans > 0 {
279 println!(" Terminated {orphans} orphan process(es).");
280 }
281
282 std::thread::sleep(std::time::Duration::from_millis(500));
283
284 let remaining = ipc::process::find_pids_by_name("lean-ctx");
285 if !remaining.is_empty() {
286 for &pid in &remaining {
287 let _ = ipc::process::force_kill(pid);
288 }
289 std::thread::sleep(std::time::Duration::from_millis(300));
290 }
291
292 daemon::cleanup_daemon_files();
293 crate::proxy_autostart::start();
294
295 match daemon::start_daemon(&[]) {
296 Ok(()) => println!(" ✓ Daemon restarted."),
297 Err(e) => {
298 eprintln!(" ✗ Daemon start failed: {e}");
299 std::process::exit(1);
300 }
301 }
302
303 println!("\n[3/4] Running safety checks…");
305 println!(" RAM guard: max {}% system", cfg.max_ram_percent);
306
307 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
308 let sessions_dir = data_dir.join("sessions");
309 let session_count = std::fs::read_dir(&sessions_dir)
310 .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
311 println!(" Sessions dir: {session_count} files");
312 }
313
314 println!("\n[4/4] Config applied successfully.");
316 println!(" Theme: {}", cfg.theme);
317 println!(" Ultra compact: {}", cfg.ultra_compact);
318 println!(" Checkpoint: every {} calls", cfg.checkpoint_interval);
319 if let Some(ref root) = cfg.project_root {
320 println!(" Project root: {root}");
321 }
322}
323
324fn cmd_config_path() {
333 let Some(p) = config::Config::path() else {
334 eprintln!("Error: cannot resolve a config directory");
335 std::process::exit(1);
336 };
337 println!("{}", p.display());
338}
339
340fn cmd_validate() {
341 print_config_provenance();
345
346 let schema = config::schema::ConfigSchema::generate();
347 let known = schema.known_keys();
348
349 let path = match config::Config::path() {
350 Some(p) if p.exists() => p,
351 Some(p) => {
352 println!("[OK] No config.toml at {} — using defaults.", p.display());
353 return;
354 }
355 None => {
356 println!("[OK] No config dir resolved — using defaults.");
357 return;
358 }
359 };
360
361 let raw = match std::fs::read_to_string(&path) {
362 Ok(s) => s,
363 Err(e) => {
364 eprintln!("[ERROR] Cannot read {}: {e}", path.display());
365 std::process::exit(1);
366 }
367 };
368
369 let table: toml::Table = match raw.parse() {
370 Ok(t) => t,
371 Err(e) => {
372 eprintln!("[ERROR] Invalid TOML: {e}");
373 std::process::exit(1);
374 }
375 };
376
377 let mut warnings = 0u32;
378 let mut validated = 0u32;
379
380 fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
381 for (k, v) in table {
382 let full = if prefix.is_empty() {
383 k.clone()
384 } else {
385 format!("{prefix}.{k}")
386 };
387 match v {
388 toml::Value::Table(sub) => collect_keys(sub, &full, out),
389 toml::Value::Array(arr) => {
390 out.push(full.clone());
391 for item in arr {
392 if let toml::Value::Table(sub) = item {
393 for sk in sub.keys() {
394 out.push(format!("{full}[].{sk}"));
395 }
396 }
397 }
398 }
399 _ => out.push(full),
400 }
401 }
402 }
403
404 let mut user_keys = Vec::new();
405 collect_keys(&table, "", &mut user_keys);
406
407 for uk in &user_keys {
408 let base = uk.split("[].").next().unwrap_or(uk);
409 let field = uk.rsplit("[].").next().unwrap_or("");
410 let check_key = if uk.contains("[].") {
411 format!("{base}.{field}")
412 } else {
413 uk.clone()
414 };
415
416 if known.contains(&check_key)
417 || known
418 .iter()
419 .any(|k| check_key.starts_with(&format!("{k}.")))
420 {
421 validated += 1;
422 } else {
423 warnings += 1;
424 let suggestion = find_closest(&check_key, &known);
425 if let Some(sug) = suggestion {
426 eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
427 } else {
428 eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
429 }
430 }
431 }
432
433 let cfg = config::Config::load();
434 let budget = cfg.max_disk_mb_effective();
435 if budget > 0 {
436 let explicit_archive = cfg.archive.max_disk_mb;
437 let explicit_bm25 = cfg.bm25_max_cache_mb;
438 let sum = explicit_archive + explicit_bm25;
439 if sum > budget {
440 warnings += 1;
441 println!(
442 " ⚠ max_disk_mb={budget} but archive.max_disk_mb({explicit_archive}) + bm25_max_cache_mb({explicit_bm25}) = {sum} exceeds budget"
443 );
444 }
445 }
446
447 let total = validated + warnings;
448 if warnings == 0 {
449 println!(
450 "[OK] All {total} keys validated successfully ({}).",
451 path.display()
452 );
453 } else {
454 println!(
455 "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
456 path.display()
457 );
458 std::process::exit(1);
459 }
460}
461
462fn print_config_provenance() {
468 let prov = config::Config::provenance();
469
470 println!("Config source:");
471 match &prov.config_path {
472 Some(p) if prov.config_exists => println!(" config.toml: {} (exists)", p.display()),
473 Some(p) => println!(
474 " config.toml: {} (missing — using defaults)",
475 p.display()
476 ),
477 None => println!(" config.toml: <no config dir resolved — using defaults>"),
478 }
479 println!(
480 " layout pin: {}",
481 if prov.xdg_pinned { "xdg" } else { "unpinned" }
482 );
483
484 if let Some(err) = &prov.parse_error {
485 println!(" [!] parse error: config.toml is unparseable — running on DEFAULTS:");
486 println!(" {err}");
487 println!(" Run `lean-ctx doctor --fix` to repair.");
488 }
489
490 if prov.local_exists && !prov.local_keys.is_empty() {
491 let path = prov
492 .local_path
493 .as_ref()
494 .map(|p| p.display().to_string())
495 .unwrap_or_default();
496 println!(
497 " [!] project-local: {path} overrides {}",
498 prov.local_keys.join(", ")
499 );
500 println!(" (these win over the global config for this project)");
501 }
502
503 if !prov.env_overrides.is_empty() {
504 let list = prov
505 .env_overrides
506 .iter()
507 .map(|e| format!("{} ({})", e.var, e.setting))
508 .collect::<Vec<_>>()
509 .join(", ");
510 println!(" [!] env override: {list}");
511 println!(
512 " (these win over config.toml; unset them for saved values to apply)"
513 );
514 }
515
516 if prov.has_shadow() {
517 println!(
518 " -> A saved setting can appear to \"reset\" because a source above shadows it (GH #450)."
519 );
520 }
521 println!();
522}
523
524fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
530 use crate::core::levenshtein::levenshtein;
531 let mut best: Option<(usize, &str)> = None;
532 for candidate in haystack {
533 let d = levenshtein(needle, candidate);
534 if d <= 3 && best.as_ref().is_none_or(|(best_d, _)| d < *best_d) {
535 best = Some((d, candidate));
536 }
537 }
538 if best.is_some() {
539 return best.map(|(_, s)| s.to_string());
540 }
541 let leaf = needle.rsplit('.').next().unwrap_or(needle);
542 let mut leaf_best: Option<(usize, &str)> = None;
543 for candidate in haystack {
544 let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
545 let d = levenshtein(leaf, cand_leaf);
546 if d <= 2 && leaf_best.as_ref().is_none_or(|(best_d, _)| d < *best_d) {
547 leaf_best = Some((d, candidate));
548 }
549 }
550 leaf_best.map(|(_, s)| s.to_string())
551}
552
553fn normalize_optional_upstream(value: &str) -> Option<String> {
554 use crate::core::config::normalize_url_opt;
555 let trimmed = value.trim();
556 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
557 None
558 } else {
559 normalize_url_opt(trimmed)
560 }
561}
562
563pub fn cmd_benchmark(args: &[String]) {
564 use crate::core::benchmark;
565 use crate::core::benchmark_compare;
566
567 let action = args.first().map_or("run", std::string::String::as_str);
568
569 match action {
570 "--help" | "-h" => {
571 println!("Usage: lean-ctx benchmark run [path] [--json]");
572 println!(" lean-ctx benchmark report [path]");
573 println!(" lean-ctx benchmark replay <sessions.json> [--output report.md]");
574 println!(" lean-ctx benchmark eval [path] [--json]");
575 println!(" lean-ctx benchmark eval-ab [path] [--suite file.ndjson] [--json]");
576 println!(" lean-ctx benchmark compare [--repo path] [--output file.md]");
577 println!(" lean-ctx benchmark scorecard [--json] [--output file]");
578 println!(" lean-ctx benchmark dual-arm [--json] [--output file]");
579 println!(
580 " lean-ctx benchmark tasks [--config stock|standard|aggressive] [--repeats N] [--json|--markdown] [--output file]"
581 );
582 println!(
583 " lean-ctx benchmark study [--suite humaneval] [--arms control,compress,route,combined] [--model MODEL] [--repeats N] [--timeout SECS] [--json] [--output file]"
584 );
585 }
586 "tasks" => {
587 super::benchmark_tasks_cmd::cmd_benchmark_tasks(&args[1..]);
588 }
589 "study" => {
590 super::benchmark_study_cmd::cmd_benchmark_study(&args[1..]);
591 }
592 "dual-arm" => {
593 let is_json = args.iter().any(|a| a == "--json");
594 let output = parse_flag_value(args, "--output");
595 match crate::core::scorecard::dual_arm::run_dual_arm() {
596 Ok(sc) => {
597 let rendered = if is_json { sc.to_json() } else { sc.to_human() };
598 if let Some(path) = output {
599 if let Err(e) = std::fs::write(&path, &rendered) {
600 eprintln!("Failed to write dual-arm scorecard to {path}: {e}");
601 std::process::exit(1);
602 }
603 eprintln!("Wrote dual-arm scorecard to {path}");
604 } else {
605 print!("{rendered}");
606 }
607 }
608 Err(e) => {
609 eprintln!("Dual-arm bench failed: {e}");
610 std::process::exit(1);
611 }
612 }
613 }
614 "scorecard" => {
615 let is_json = args.iter().any(|a| a == "--json");
616 let output = parse_flag_value(args, "--output");
617 match crate::core::scorecard::run_scorecard() {
618 Ok(sc) => {
619 let rendered = if is_json { sc.to_json() } else { sc.to_human() };
620 if let Some(path) = output {
621 if let Err(e) = std::fs::write(&path, &rendered) {
622 eprintln!("Failed to write scorecard to {path}: {e}");
623 std::process::exit(1);
624 }
625 eprintln!("Wrote scorecard to {path}");
626 } else {
627 print!("{rendered}");
628 }
629 }
630 Err(e) => {
631 eprintln!("Scorecard failed: {e}");
632 std::process::exit(1);
633 }
634 }
635 }
636 "eval" => {
637 let path = args.get(1).map_or(".", std::string::String::as_str);
638 let is_json = args.iter().any(|a| a == "--json");
639 let root = std::path::Path::new(path);
640
641 let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
642 let cfg = crate::core::hybrid_search::HybridConfig::from_config();
643 let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
644
645 if queries.is_empty() {
646 eprintln!("No symbols found — cannot generate eval queries.");
647 std::process::exit(1);
648 }
649
650 let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
651 if is_json {
652 if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
653 println!("{json}");
654 }
655 } else {
656 print!("{scorecard}");
657 }
658 }
659 "eval-ab" => {
660 let path = args
661 .get(1)
662 .filter(|a| !a.starts_with("--"))
663 .map_or(".", std::string::String::as_str);
664 let is_json = args.iter().any(|a| a == "--json");
665 let root = std::path::Path::new(path);
666 if !root.exists() {
667 eprintln!("Path does not exist: {path}");
668 std::process::exit(1);
669 }
670
671 let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
672 let cfg = crate::core::hybrid_search::HybridConfig::from_config();
673
674 let queries = match parse_flag_value(args, "--suite") {
675 Some(suite) => {
676 match crate::core::eval_harness::load_suite(std::path::Path::new(&suite)) {
677 Ok(q) => q,
678 Err(e) => {
679 eprintln!("Failed to load suite {suite}: {e}");
680 std::process::exit(1);
681 }
682 }
683 }
684 None => crate::core::eval_harness::generate_self_eval(&index, 50),
685 };
686
687 if queries.is_empty() {
688 eprintln!("No eval queries (empty suite / no symbols indexed).");
689 std::process::exit(1);
690 }
691
692 let report = crate::core::eval_harness::run_ab(root, &queries, &index, &cfg);
693 if is_json {
694 println!("{}", report.to_json());
695 } else {
696 print!("{report}");
697 }
698 }
699 "run" => {
700 let path = args.get(1).map_or(".", std::string::String::as_str);
701 let is_json = args.iter().any(|a| a == "--json");
702
703 let result = benchmark::run_project_benchmark(path);
704 if is_json {
705 println!("{}", benchmark::format_json(&result));
706 } else {
707 println!("{}", benchmark::format_terminal(&result));
708 }
709 }
710 "report" => {
711 let path = args.get(1).map_or(".", std::string::String::as_str);
712 let result = benchmark::run_project_benchmark(path);
713 println!("{}", benchmark::format_markdown(&result));
714 }
715 "replay" => {
716 let Some(input) = args.get(1).filter(|arg| !arg.starts_with("--")) else {
717 eprintln!("Usage: lean-ctx benchmark replay <sessions.json> [--output report.md]");
718 std::process::exit(1);
719 };
720 let result = crate::core::quality_benchmark::load_replay(std::path::Path::new(input))
721 .and_then(|suite| crate::core::quality_benchmark::replay(&suite));
722 match result {
723 Ok(report) => {
724 let markdown = crate::core::quality_benchmark::format_markdown(&report);
725 if let Some(output) = parse_flag_value(args, "--output") {
726 if let Err(error) = std::fs::write(&output, markdown) {
727 eprintln!("Failed to write benchmark report to {output}: {error}");
728 std::process::exit(1);
729 }
730 eprintln!("Wrote compression quality report to {output}");
731 } else {
732 print!("{markdown}");
733 }
734 }
735 Err(error) => {
736 eprintln!("Benchmark replay failed: {error:#}");
737 std::process::exit(1);
738 }
739 }
740 }
741 "compare" => {
742 let repo = parse_flag_value(args, "--repo").unwrap_or_else(|| ".".to_string());
743 let output = parse_flag_value(args, "--output");
744
745 let root = std::path::Path::new(&repo);
746 if !root.exists() {
747 eprintln!("Repository path does not exist: {repo}");
748 std::process::exit(1);
749 }
750
751 let report = benchmark_compare::run_compare(root, output.as_deref());
752
753 println!("{}", benchmark_compare::report::generate_terminal(&report));
754
755 if output.is_none() {
756 eprintln!("Tip: use --output BENCHMARKS.md to save the full markdown report");
757 }
758 }
759 _ => {
760 if std::path::Path::new(action).exists() {
761 let result = benchmark::run_project_benchmark(action);
762 println!("{}", benchmark::format_terminal(&result));
763 } else {
764 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
765 eprintln!(" lean-ctx benchmark report [path]");
766 eprintln!(" lean-ctx benchmark replay <sessions.json> [--output report.md]");
767 eprintln!(" lean-ctx benchmark eval [path] [--json]");
768 eprintln!(
769 " lean-ctx benchmark eval-ab [path] [--suite file.ndjson] [--json]"
770 );
771 eprintln!(" lean-ctx benchmark compare [--repo path] [--output file.md]");
772 eprintln!(" lean-ctx benchmark scorecard [--json] [--output file]");
773 std::process::exit(1);
774 }
775 }
776 }
777}
778
779fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
780 args.iter()
781 .position(|a| a == flag)
782 .and_then(|i| args.get(i + 1))
783 .cloned()
784}
785
786fn load_mcp_live_stats() -> Option<serde_json::Value> {
787 let path = crate::core::paths::state_dir().ok()?.join("mcp-live.json");
788 let content = std::fs::read_to_string(path).ok()?;
789 serde_json::from_str(&content).ok()
790}
791
792fn stats_json_value(
793 store: &crate::core::stats::StatsStore,
794 mcp_cache: Option<serde_json::Value>,
795) -> serde_json::Value {
796 let mut value = serde_json::to_value(store).unwrap_or_else(|_| serde_json::json!({}));
797 if let (Some(object), Some(mcp_cache)) = (value.as_object_mut(), mcp_cache) {
798 object.insert("mcp_cache".to_string(), mcp_cache);
799 }
800 value
801}
802
803fn mcp_cache_stats_lines(value: &serde_json::Value) -> Vec<String> {
804 let metric = |key| {
805 value
806 .get(key)
807 .and_then(serde_json::Value::as_u64)
808 .unwrap_or(0)
809 };
810 let mcp_reads = metric("total_reads");
811 let mcp_hits = metric("cache_hits");
812 let mcp_rate = if mcp_reads > 0 {
813 (mcp_hits as f64 / mcp_reads as f64 * 100.0).round() as u32
814 } else {
815 0
816 };
817 let updated = value
818 .get("updated_at")
819 .and_then(serde_json::Value::as_str)
820 .unwrap_or("unknown");
821 let mut lines = vec![
822 "MCP Session Cache (ctx_read via AI editor):".to_string(),
823 format!(" Reads: {mcp_reads}"),
824 format!(" Hits: {mcp_hits}"),
825 format!(" Hit Rate: {mcp_rate}%"),
826 format!(" Tokens Saved: {}", metric("tokens_saved")),
827 format!(" Last Updated: {updated}"),
828 ];
829
830 let dedup_reads = metric("dedup_reads");
831 if dedup_reads > 0 {
832 let dedup_hits = metric("dedup_hits");
833 let dedup_rate = dedup_hits as f64 / dedup_reads as f64 * 100.0;
834 lines.extend([
835 String::new(),
836 "Content Dedup (repeated ctx_read output):".to_string(),
837 format!(" Reads Checked: {dedup_reads}"),
838 format!(" Hits: {dedup_hits}"),
839 format!(" Hit Rate: {dedup_rate:.1}%"),
840 format!(" Tokens Saved: {}", metric("dedup_tokens_saved")),
841 ]);
842 }
843
844 lines
845}
846
847pub fn cmd_stats(args: &[String]) {
848 match args.first().map(std::string::String::as_str) {
849 Some("reset-cep") => {
850 crate::core::stats::reset_cep();
851 println!("CEP stats reset. Shell hook data preserved.");
852 }
853 Some("json") => {
854 let store = crate::core::stats::load();
855 let value = stats_json_value(&store, load_mcp_live_stats());
856 println!(
857 "{}",
858 serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
859 );
860 }
861 _ => {
862 let store = crate::core::stats::load();
863 let input_saved = store
864 .total_input_tokens
865 .saturating_sub(store.total_output_tokens);
866 let pct = if store.total_input_tokens > 0 {
867 input_saved as f64 / store.total_input_tokens as f64 * 100.0
868 } else {
869 0.0
870 };
871 println!("Commands: {}", store.total_commands);
872 println!("Input: {} tokens", store.total_input_tokens);
873 println!("Output: {} tokens", store.total_output_tokens);
874 println!("Saved: {input_saved} tokens ({pct:.1}%)");
875 println!();
876 println!("CEP sessions: {}", store.cep.sessions);
877 println!(
878 "CEP tokens: {} → {}",
879 store.cep.total_tokens_original, store.cep.total_tokens_compressed
880 );
881 println!();
882 println!("Subcommands: stats reset-cep | stats json");
883 }
884 }
885}
886
887pub fn cmd_cache(args: &[String]) {
888 use crate::core::cli_cache;
889 match args.first().map(std::string::String::as_str) {
890 Some("clear") => {
891 let count = cli_cache::clear();
892 println!("Cleared {count} cached entries.");
893 }
894 Some("reset") => {
895 let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
896 if project_flag {
897 let root =
898 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
899 if let Some(root) = root {
900 let count = cli_cache::clear_project(&root);
901 println!("Reset {count} cache entries for project: {root}");
902 } else {
903 eprintln!("No active project root found. Start a session first.");
904 std::process::exit(1);
905 }
906 } else {
907 let count = cli_cache::clear();
908 println!("Reset all {count} cache entries.");
909 }
910 }
911 Some("stats") => {
912 let (hits, reads, entries) = cli_cache::stats();
913 let rate = if reads > 0 {
914 (hits as f64 / reads as f64 * 100.0).round() as u32
915 } else {
916 0
917 };
918 println!("CLI Cache Stats (lean-ctx read / lean-ctx grep):");
919 println!(" Entries: {entries}");
920 println!(" Reads: {reads}");
921 println!(" Hits: {hits}");
922 println!(" Hit Rate: {rate}%");
923
924 if let Some(value) = load_mcp_live_stats() {
925 println!();
926 for line in mcp_cache_stats_lines(&value) {
927 println!("{line}");
928 }
929 } else {
930 println!();
931 println!("MCP Session Cache: no data yet (start a session with your AI editor)");
932 }
933 }
934 Some("invalidate") => {
935 if args.len() < 2 {
936 eprintln!("Usage: lean-ctx cache invalidate <path>");
937 std::process::exit(1);
938 }
939 cli_cache::invalidate(&args[1]);
940 println!("Invalidated cache for {}", args[1]);
941 }
942 Some("prune") => {
943 let bm25 = prune_bm25_caches();
944 let graph = prune_graph_caches();
945 let archive_before = crate::core::archive::disk_usage_bytes()
948 + crate::core::archive_fts::db_size_bytes();
949 let archive_removed = crate::core::archive::cleanup();
950 let _ = crate::core::archive_fts::enforce_cap();
951 let archive_after = crate::core::archive::disk_usage_bytes()
952 + crate::core::archive_fts::db_size_bytes();
953 let archive_freed = archive_before.saturating_sub(archive_after);
954
955 let orphans = crate::core::knowledge::maintenance::prune_orphaned_stores();
959
960 let removed = bm25.removed + graph.removed + archive_removed + orphans.removed as u32;
961 let failed = bm25.failed + graph.failed;
962 let freed =
963 bm25.bytes_freed + graph.bytes_freed + archive_freed + orphans.reclaimed_bytes;
964 println!(
965 "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {}, archive: {}, orphaned stores: {}, failed: {})",
966 removed,
967 freed as f64 / 1_048_576.0,
968 bm25.removed,
969 graph.removed,
970 archive_removed,
971 orphans.removed,
972 failed,
973 );
974 }
975 _ => {
976 let (hits, reads, entries) = cli_cache::stats();
977 let rate = if reads > 0 {
978 (hits as f64 / reads as f64 * 100.0).round() as u32
979 } else {
980 0
981 };
982 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
983 println!();
984 println!("Subcommands:");
985 println!(" cache stats Show detailed stats");
986 println!(" cache clear Clear all cached entries");
987 println!(" cache reset Reset all cache (or --project for current project only)");
988 println!(" cache invalidate Remove specific file from cache");
989 println!(
990 " cache prune Reclaim BM25 + graph indexes, archive, and orphaned knowledge stores"
991 );
992 }
993 }
994}
995
996pub struct PruneResult {
997 pub scanned: u32,
998 pub removed: u32,
999 pub failed: u32,
1000 pub bytes_freed: u64,
1001}
1002
1003fn record_removed_file(result: &mut PruneResult, path: &std::path::Path, label: &str) {
1004 let bytes = std::fs::metadata(path).map_or(0, |meta| meta.len());
1005
1006 match std::fs::remove_file(path) {
1007 Ok(()) => {
1008 result.bytes_freed += bytes;
1009 result.removed += 1;
1010 println!(" {label}: {}", path.display());
1011 }
1012 Err(error) => {
1013 result.failed += 1;
1014 eprintln!(" Failed to remove {}: {error}", path.display());
1015 }
1016 }
1017}
1018
1019fn record_removed_dir(
1020 result: &mut PruneResult,
1021 path: &std::path::Path,
1022 bytes: u64,
1023 message: impl FnOnce() -> String,
1024) {
1025 match std::fs::remove_dir_all(path) {
1026 Ok(()) => {
1027 result.bytes_freed += bytes;
1028 result.removed += 1;
1029 println!(" {}", message());
1030 }
1031 Err(error) => {
1032 result.failed += 1;
1033 eprintln!(" Failed to remove {}: {error}", path.display());
1034 }
1035 }
1036}
1037
1038pub fn prune_bm25_caches() -> PruneResult {
1039 let mut result = PruneResult {
1040 scanned: 0,
1041 removed: 0,
1042 failed: 0,
1043 bytes_freed: 0,
1044 };
1045
1046 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1047 return result;
1048 };
1049 let vectors_dir = data_dir.join("vectors");
1050 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
1051 return result;
1052 };
1053
1054 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
1055
1056 for entry in entries.flatten() {
1057 let dir = entry.path();
1058 if !dir.is_dir() {
1059 continue;
1060 }
1061 result.scanned += 1;
1062
1063 for q_name in &[
1064 "bm25_index.json.quarantined",
1065 "bm25_index.bin.quarantined",
1066 "bm25_index.bin.zst.quarantined",
1067 ] {
1068 let quarantined = dir.join(q_name);
1069 if quarantined.exists() {
1070 record_removed_file(&mut result, &quarantined, "Removed quarantined");
1071 }
1072 }
1073
1074 let index_path = if dir.join("bm25_index.bin.zst").exists() {
1075 dir.join("bm25_index.bin.zst")
1076 } else if dir.join("bm25_index.bin").exists() {
1077 dir.join("bm25_index.bin")
1078 } else {
1079 dir.join("bm25_index.json")
1080 };
1081 if let Ok(meta) = std::fs::metadata(&index_path)
1082 && meta.len() > max_bytes
1083 {
1084 record_removed_file(&mut result, &index_path, "Removed oversized");
1085 }
1086
1087 let marker = dir.join("project_root.txt");
1088 if let Ok(root_str) = std::fs::read_to_string(&marker) {
1089 let root_path = std::path::Path::new(root_str.trim());
1090 if !root_path.exists() {
1091 let freed = dir_size(&dir);
1092 record_removed_dir(&mut result, &dir, freed, || {
1093 format!(
1094 "Removed orphaned ({:.1} MB, project gone: {}): {}",
1095 freed as f64 / 1_048_576.0,
1096 root_str.trim(),
1097 dir.display()
1098 )
1099 });
1100 }
1101 }
1102 }
1103
1104 result
1105}
1106
1107pub fn prune_graph_caches() -> PruneResult {
1108 let mut result = PruneResult {
1109 scanned: 0,
1110 removed: 0,
1111 failed: 0,
1112 bytes_freed: 0,
1113 };
1114
1115 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1116 return result;
1117 };
1118 let graphs_dir = data_dir.join("graphs");
1119 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
1120 return result;
1121 };
1122
1123 for entry in entries.flatten() {
1124 let dir = entry.path();
1125 if !dir.is_dir() {
1126 continue;
1127 }
1128 result.scanned += 1;
1129
1130 let meta_file = dir.join("graph.meta.json");
1134 let db_file = dir.join("graph.db");
1135 if !meta_file.exists() && !db_file.exists() {
1136 continue;
1137 }
1138
1139 let root_from_meta = try_read_project_root_from_graph(&meta_file);
1140 if let Some(root) = root_from_meta
1141 && !root.is_empty()
1142 && !std::path::Path::new(&root).exists()
1143 {
1144 let freed = dir_size(&dir);
1145 record_removed_dir(&mut result, &dir, freed, || {
1146 format!(
1147 "Removed orphaned graph ({:.1} MB, project gone: {}): {}",
1148 freed as f64 / 1_048_576.0,
1149 root,
1150 dir.display()
1151 )
1152 });
1153 continue;
1154 }
1155
1156 if let Ok(meta) = std::fs::metadata(&db_file)
1160 && meta.len() > 100 * 1024 * 1024
1161 {
1162 let freed = dir_size(&dir);
1163 record_removed_dir(&mut result, &dir, freed, || {
1164 format!(
1165 "Removed oversized graph ({:.1} MB): {}",
1166 freed as f64 / 1_048_576.0,
1167 dir.display()
1168 )
1169 });
1170 }
1171 }
1172
1173 result
1174}
1175
1176fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
1179 let content = std::fs::read_to_string(path).ok()?;
1180 let val: serde_json::Value = serde_json::from_str(&content).ok()?;
1181 val.get("project_root")?.as_str().map(String::from)
1182}
1183
1184pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
1185# Full reference: https://leanctx.com/docs/configuration
1186# For all settings: lean-ctx config init --full
1187#
1188# Optional named overlay. LEAN_CTX_CONFIG_PROFILE overrides this selection:
1189# config_profile = "cloud"
1190# [profiles.local]
1191# compression_level = "lite"
1192# [profiles.cloud]
1193# compression_level = "standard"
1194
1195# ── High-Level Knobs ─────────────────────────────────────────────────
1196# These auto-adjust advanced settings. Override individual values below
1197# only if you need fine-grained control.
1198
1199# Output style for the model's prose (not tool-output compression):
1200# off — no style guidance
1201# lite — plain-English concise (default; readable, still token-saving)
1202# standard / max — denser symbolic "power modes" (opt-in)
1203compression_level = "lite"
1204
1205# RAM/feature trade-off: low | balanced | performance
1206memory_profile = "balanced"
1207
1208# Maximum % of system RAM lean-ctx may use (1-50)
1209max_ram_percent = 5
1210
1211# Total disk budget in MB (0 = use individual limits).
1212# Distributes proportionally: archive ~25%, BM25 cache ~10%.
1213# max_disk_mb = 2000
1214
1215# Auto-purge data older than N days (0 = disabled).
1216# Flows into archive.max_age_hours.
1217# max_staleness_days = 30
1218
1219# Explicit project paths to scan/index (default: auto-detect).
1220# [ide_paths]
1221# cursor = ["/home/user/projects/app1"]
1222
1223# ── Proxy ────────────────────────────────────────────────────────────
1224# proxy_enabled = false
1225# proxy_port = 3128
1226"#;
1227
1228fn write_simplified_config() -> Result<String, String> {
1229 let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
1230 if let Some(dir) = path.parent() {
1231 std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
1232 }
1233 std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
1234 Ok(path.to_string_lossy().to_string())
1235}
1236
1237fn cmd_show_effective() {
1238 let cfg = config::Config::load();
1239 let compression = config::CompressionLevel::effective(&cfg);
1240 let policy = cfg.memory_policy_effective().unwrap_or_default();
1241
1242 println!("{}", box_top("Simplified (high-level)"));
1243 box_row(&format!(
1244 " compression_level = {:10} {}",
1245 format!("{compression:?}"),
1246 source_hint(
1247 "LEAN_CTX_COMPRESSION",
1248 cfg.compression_level != config::CompressionLevel::Off
1249 )
1250 ));
1251 box_row(&format!(
1252 " max_disk_mb = {:10} {}",
1253 cfg.max_disk_mb_effective(),
1254 source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
1255 ));
1256 box_row(&format!(
1257 " max_ram_percent = {:10} {}",
1258 cfg.max_ram_percent,
1259 source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
1260 ));
1261 box_row(&format!(
1262 " max_staleness_days = {:10} {}",
1263 cfg.max_staleness_days_effective(),
1264 source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
1265 ));
1266 box_row(&format!(
1267 " memory_profile = {:10} {}",
1268 format!("{:?}", cfg.memory_profile),
1269 source_hint("LEAN_CTX_MEMORY_PROFILE", false)
1270 ));
1271 println!("{}", box_bottom());
1272
1273 println!();
1274 println!("{}", box_top("Derived effective limits"));
1275 box_row(&format!(
1276 " archive_max_disk_mb = {:>6} MB",
1277 cfg.archive_max_disk_mb_effective()
1278 ));
1279 box_row(&format!(
1280 " bm25_max_cache_mb = {:>6} MB",
1281 cfg.bm25_max_cache_mb_effective()
1282 ));
1283 box_row(&format!(
1284 " archive_max_age_hours = {:>6} h",
1285 cfg.archive_max_age_hours_effective()
1286 ));
1287 box_row(&format!(
1288 " graph_index_max_files = {:>6}",
1289 cfg.graph_index_max_files
1290 ));
1291 box_row("");
1292 box_row(&format!(
1293 " memory.knowledge.max_facts = {:>6}",
1294 policy.knowledge.max_facts
1295 ));
1296 box_row(&format!(
1297 " memory.knowledge.max_patterns = {:>6}",
1298 policy.knowledge.max_patterns
1299 ));
1300 box_row(&format!(
1301 " memory.episodic.max_episodes = {:>6}",
1302 policy.episodic.max_episodes
1303 ));
1304 box_row(&format!(
1305 " memory.procedural.max_procedures = {:>4}",
1306 policy.procedural.max_procedures
1307 ));
1308 println!("{}", box_bottom());
1309
1310 if cfg.max_disk_mb_effective() > 0 {
1311 println!();
1312 println!(
1313 " ℹ max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
1314 cfg.max_disk_mb_effective(),
1315 (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
1316 );
1317 }
1318}
1319
1320const SHOW_BOX_W: usize = 60;
1322
1323fn box_top(label: &str) -> String {
1325 let head = format!("─── {label} ");
1326 let fill = SHOW_BOX_W.saturating_sub(crate::core::theme::visual_len(&head));
1327 format!("╭{head}{}╮", "─".repeat(fill))
1328}
1329
1330fn box_bottom() -> String {
1331 format!("╰{}╯", "─".repeat(SHOW_BOX_W))
1332}
1333
1334fn box_row(content: &str) {
1337 println!("│{}│", crate::core::theme::pad_right(content, SHOW_BOX_W));
1338}
1339
1340fn source_hint(env_var: &str, config_set: bool) -> &'static str {
1341 if std::env::var(env_var).is_ok() {
1342 "← env"
1343 } else if config_set {
1344 "← config"
1345 } else {
1346 "← default"
1347 }
1348}
1349
1350fn dir_size(path: &std::path::Path) -> u64 {
1351 let mut total = 0u64;
1352 if let Ok(entries) = std::fs::read_dir(path) {
1353 for entry in entries.flatten() {
1354 let p = entry.path();
1355 if p.is_file() {
1356 total += std::fs::metadata(&p).map_or(0, |m| m.len());
1357 } else if p.is_dir() {
1358 total += dir_size(&p);
1359 }
1360 }
1361 }
1362 total
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367 use super::*;
1368
1369 #[test]
1370 fn mcp_cache_stats_distinguish_session_cache_and_content_dedup() {
1371 let current = serde_json::json!({
1372 "total_reads": 55,
1373 "cache_hits": 2,
1374 "tokens_saved": 178_015,
1375 "dedup_reads": 10,
1376 "dedup_hits": 8,
1377 "dedup_tokens_saved": 12_345,
1378 "updated_at": "2026-07-24T14:17:30+02:00"
1379 });
1380 let lines = mcp_cache_stats_lines(¤t);
1381 assert!(lines.iter().any(|line| line == " Hit Rate: 4%"));
1382 assert!(
1383 lines
1384 .iter()
1385 .any(|line| line == "Content Dedup (repeated ctx_read output):")
1386 );
1387 assert!(lines.iter().any(|line| line == " Hit Rate: 80.0%"));
1388 assert!(lines.iter().any(|line| line == " Tokens Saved: 12345"));
1389
1390 let legacy = serde_json::json!({"total_reads": 3, "cache_hits": 1});
1391 let legacy_lines = mcp_cache_stats_lines(&legacy);
1392 assert!(
1393 legacy_lines
1394 .iter()
1395 .all(|line| !line.contains("Content Dedup"))
1396 );
1397 }
1398
1399 #[test]
1400 fn stats_json_embeds_mcp_cache_snapshot() {
1401 let store = crate::core::stats::StatsStore::default();
1402 let cache = serde_json::json!({"cache_hits": 2, "dedup_hits": 8});
1403 let value = stats_json_value(&store, Some(cache));
1404
1405 assert_eq!(value["mcp_cache"]["cache_hits"], 2);
1406 assert_eq!(value["mcp_cache"]["dedup_hits"], 8);
1407 assert!(stats_json_value(&store, None).get("mcp_cache").is_none());
1408 }
1409
1410 #[test]
1411 fn show_box_borders_line_up() {
1412 use crate::core::theme::{pad_right, visual_len};
1413 let bottom = visual_len(&box_bottom());
1414 for label in ["Simplified (high-level)", "Derived effective limits", ""] {
1415 assert_eq!(visual_len(&box_top(label)), bottom, "top border: {label:?}");
1416 }
1417 for row in ["", " x = 1", &" long ".repeat(40)] {
1419 assert_eq!(visual_len(&pad_right(row, SHOW_BOX_W)) + 2, bottom);
1420 }
1421 }
1422
1423 fn merged_max_ram(cfg: &config::Config, existing: &str) -> u8 {
1427 let dir = tempfile::tempdir().unwrap();
1428 let path = dir.path().join("config.toml");
1429 std::fs::write(&path, existing).unwrap();
1430 let new_content = toml::to_string_pretty(cfg).unwrap();
1431 let baseline = toml::from_str::<config::Config>("").unwrap();
1432 let defaults = toml::to_string_pretty(&baseline).unwrap();
1433 crate::config_io::write_toml_preserving_minimal(&path, &new_content, &defaults).unwrap();
1434 let written = std::fs::read_to_string(&path).unwrap();
1435 toml::from_str::<config::Config>(&written)
1436 .unwrap()
1437 .max_ram_percent
1438 }
1439
1440 #[test]
1441 fn full_init_uses_existing_values_not_defaults() {
1442 let existing = "max_ram_percent = 30\ncompression_level = \"standard\"\n";
1443 let cfg = config_for_full_init(Some(existing)).expect("parse existing");
1444 assert_eq!(cfg.max_ram_percent, 30, "must keep the user's value, not 5");
1445 assert_eq!(cfg.compression_level, config::CompressionLevel::Standard);
1446 }
1447
1448 #[test]
1449 fn full_init_falls_back_to_defaults_on_fresh_install() {
1450 let cfg = config_for_full_init(None).expect("default");
1451 assert_eq!(
1452 cfg.max_ram_percent,
1453 config::Config::default().max_ram_percent
1454 );
1455 let cfg_empty = config_for_full_init(Some(" \n")).expect("blank -> default");
1456 assert_eq!(
1457 cfg_empty.max_ram_percent,
1458 config::Config::default().max_ram_percent
1459 );
1460 }
1461
1462 #[test]
1463 fn full_init_refuses_unparseable_config() {
1464 assert!(config_for_full_init(Some("max_ram_percent = = =")).is_err());
1465 }
1466
1467 #[test]
1469 fn full_init_preserves_value_through_save_merge() {
1470 let existing = "max_ram_percent = 30\n";
1471 let cfg = config_for_full_init(Some(existing)).unwrap();
1472 assert_eq!(
1473 merged_max_ram(&cfg, existing),
1474 30,
1475 "user value must survive `config init --full`"
1476 );
1477 }
1478
1479 #[test]
1483 fn default_seed_resets_value_root_cause_marker() {
1484 let existing = "max_ram_percent = 30\n";
1485 assert_eq!(
1486 merged_max_ram(&config::Config::default(), existing),
1487 5,
1488 "default seed resets to 5 — the #443 regression we fixed"
1489 );
1490 }
1491}