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