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