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