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 eval-ab [path] [--suite file.ndjson] [--json]");
540 println!(" lean-ctx benchmark compare [--repo path] [--output file.md]");
541 println!(" lean-ctx benchmark scorecard [--json] [--output file]");
542 println!(" lean-ctx benchmark dual-arm [--json] [--output file]");
543 }
544 "dual-arm" => {
545 let is_json = args.iter().any(|a| a == "--json");
546 let output = parse_flag_value(args, "--output");
547 match crate::core::scorecard::dual_arm::run_dual_arm() {
548 Ok(sc) => {
549 let rendered = if is_json { sc.to_json() } else { sc.to_human() };
550 if let Some(path) = output {
551 if let Err(e) = std::fs::write(&path, &rendered) {
552 eprintln!("Failed to write dual-arm scorecard to {path}: {e}");
553 std::process::exit(1);
554 }
555 eprintln!("Wrote dual-arm scorecard to {path}");
556 } else {
557 print!("{rendered}");
558 }
559 }
560 Err(e) => {
561 eprintln!("Dual-arm bench failed: {e}");
562 std::process::exit(1);
563 }
564 }
565 }
566 "scorecard" => {
567 let is_json = args.iter().any(|a| a == "--json");
568 let output = parse_flag_value(args, "--output");
569 match crate::core::scorecard::run_scorecard() {
570 Ok(sc) => {
571 let rendered = if is_json { sc.to_json() } else { sc.to_human() };
572 if let Some(path) = output {
573 if let Err(e) = std::fs::write(&path, &rendered) {
574 eprintln!("Failed to write scorecard to {path}: {e}");
575 std::process::exit(1);
576 }
577 eprintln!("Wrote scorecard to {path}");
578 } else {
579 print!("{rendered}");
580 }
581 }
582 Err(e) => {
583 eprintln!("Scorecard failed: {e}");
584 std::process::exit(1);
585 }
586 }
587 }
588 "eval" => {
589 let path = args.get(1).map_or(".", std::string::String::as_str);
590 let is_json = args.iter().any(|a| a == "--json");
591 let root = std::path::Path::new(path);
592
593 let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
594 let cfg = crate::core::hybrid_search::HybridConfig::from_config();
595 let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
596
597 if queries.is_empty() {
598 eprintln!("No symbols found — cannot generate eval queries.");
599 std::process::exit(1);
600 }
601
602 let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
603 if is_json {
604 if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
605 println!("{json}");
606 }
607 } else {
608 print!("{scorecard}");
609 }
610 }
611 "eval-ab" => {
612 let path = args
613 .get(1)
614 .filter(|a| !a.starts_with("--"))
615 .map_or(".", std::string::String::as_str);
616 let is_json = args.iter().any(|a| a == "--json");
617 let root = std::path::Path::new(path);
618 if !root.exists() {
619 eprintln!("Path does not exist: {path}");
620 std::process::exit(1);
621 }
622
623 let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
624 let cfg = crate::core::hybrid_search::HybridConfig::from_config();
625
626 let queries = match parse_flag_value(args, "--suite") {
627 Some(suite) => {
628 match crate::core::eval_harness::load_suite(std::path::Path::new(&suite)) {
629 Ok(q) => q,
630 Err(e) => {
631 eprintln!("Failed to load suite {suite}: {e}");
632 std::process::exit(1);
633 }
634 }
635 }
636 None => crate::core::eval_harness::generate_self_eval(&index, 50),
637 };
638
639 if queries.is_empty() {
640 eprintln!("No eval queries (empty suite / no symbols indexed).");
641 std::process::exit(1);
642 }
643
644 let report = crate::core::eval_harness::run_ab(root, &queries, &index, &cfg);
645 if is_json {
646 println!("{}", report.to_json());
647 } else {
648 print!("{report}");
649 }
650 }
651 "run" => {
652 let path = args.get(1).map_or(".", std::string::String::as_str);
653 let is_json = args.iter().any(|a| a == "--json");
654
655 let result = benchmark::run_project_benchmark(path);
656 if is_json {
657 println!("{}", benchmark::format_json(&result));
658 } else {
659 println!("{}", benchmark::format_terminal(&result));
660 }
661 }
662 "report" => {
663 let path = args.get(1).map_or(".", std::string::String::as_str);
664 let result = benchmark::run_project_benchmark(path);
665 println!("{}", benchmark::format_markdown(&result));
666 }
667 "compare" => {
668 let repo = parse_flag_value(args, "--repo").unwrap_or_else(|| ".".to_string());
669 let output = parse_flag_value(args, "--output");
670
671 let root = std::path::Path::new(&repo);
672 if !root.exists() {
673 eprintln!("Repository path does not exist: {repo}");
674 std::process::exit(1);
675 }
676
677 let report = benchmark_compare::run_compare(root, output.as_deref());
678
679 println!("{}", benchmark_compare::report::generate_terminal(&report));
680
681 if output.is_none() {
682 eprintln!("Tip: use --output BENCHMARKS.md to save the full markdown report");
683 }
684 }
685 _ => {
686 if std::path::Path::new(action).exists() {
687 let result = benchmark::run_project_benchmark(action);
688 println!("{}", benchmark::format_terminal(&result));
689 } else {
690 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
691 eprintln!(" lean-ctx benchmark report [path]");
692 eprintln!(" lean-ctx benchmark eval [path] [--json]");
693 eprintln!(
694 " lean-ctx benchmark eval-ab [path] [--suite file.ndjson] [--json]"
695 );
696 eprintln!(" lean-ctx benchmark compare [--repo path] [--output file.md]");
697 eprintln!(" lean-ctx benchmark scorecard [--json] [--output file]");
698 std::process::exit(1);
699 }
700 }
701 }
702}
703
704fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
705 args.iter()
706 .position(|a| a == flag)
707 .and_then(|i| args.get(i + 1))
708 .cloned()
709}
710
711pub fn cmd_stats(args: &[String]) {
712 match args.first().map(std::string::String::as_str) {
713 Some("reset-cep") => {
714 crate::core::stats::reset_cep();
715 println!("CEP stats reset. Shell hook data preserved.");
716 }
717 Some("json") => {
718 let store = crate::core::stats::load();
719 println!(
720 "{}",
721 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
722 );
723 }
724 _ => {
725 let store = crate::core::stats::load();
726 let input_saved = store
727 .total_input_tokens
728 .saturating_sub(store.total_output_tokens);
729 let pct = if store.total_input_tokens > 0 {
730 input_saved as f64 / store.total_input_tokens as f64 * 100.0
731 } else {
732 0.0
733 };
734 println!("Commands: {}", store.total_commands);
735 println!("Input: {} tokens", store.total_input_tokens);
736 println!("Output: {} tokens", store.total_output_tokens);
737 println!("Saved: {input_saved} tokens ({pct:.1}%)");
738 println!();
739 println!("CEP sessions: {}", store.cep.sessions);
740 println!(
741 "CEP tokens: {} → {}",
742 store.cep.total_tokens_original, store.cep.total_tokens_compressed
743 );
744 println!();
745 println!("Subcommands: stats reset-cep | stats json");
746 }
747 }
748}
749
750pub fn cmd_cache(args: &[String]) {
751 use crate::core::cli_cache;
752 match args.first().map(std::string::String::as_str) {
753 Some("clear") => {
754 let count = cli_cache::clear();
755 println!("Cleared {count} cached entries.");
756 }
757 Some("reset") => {
758 let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
759 if project_flag {
760 let root =
761 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
762 if let Some(root) = root {
763 let count = cli_cache::clear_project(&root);
764 println!("Reset {count} cache entries for project: {root}");
765 } else {
766 eprintln!("No active project root found. Start a session first.");
767 std::process::exit(1);
768 }
769 } else {
770 let count = cli_cache::clear();
771 println!("Reset all {count} cache entries.");
772 }
773 }
774 Some("stats") => {
775 let (hits, reads, entries) = cli_cache::stats();
776 let rate = if reads > 0 {
777 (hits as f64 / reads as f64 * 100.0).round() as u32
778 } else {
779 0
780 };
781 println!("CLI Cache Stats (lean-ctx read / lean-ctx grep):");
782 println!(" Entries: {entries}");
783 println!(" Reads: {reads}");
784 println!(" Hits: {hits}");
785 println!(" Hit Rate: {rate}%");
786
787 if let Ok(dir) = crate::core::paths::state_dir() {
788 let live_path = dir.join("mcp-live.json");
789 if let Ok(content) = std::fs::read_to_string(&live_path) {
790 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
791 let mcp_reads = val
792 .get("total_reads")
793 .and_then(serde_json::Value::as_u64)
794 .unwrap_or(0);
795 let mcp_hits = val
796 .get("cache_hits")
797 .and_then(serde_json::Value::as_u64)
798 .unwrap_or(0);
799 let mcp_saved = val
800 .get("tokens_saved")
801 .and_then(serde_json::Value::as_u64)
802 .unwrap_or(0);
803 let mcp_rate = if mcp_reads > 0 {
804 (mcp_hits as f64 / mcp_reads as f64 * 100.0).round() as u32
805 } else {
806 0
807 };
808 let updated = val
809 .get("updated_at")
810 .and_then(serde_json::Value::as_str)
811 .unwrap_or("unknown");
812 println!();
813 println!("MCP Session Cache (ctx_read via AI editor):");
814 println!(" Reads: {mcp_reads}");
815 println!(" Hits: {mcp_hits}");
816 println!(" Hit Rate: {mcp_rate}%");
817 println!(" Tokens Saved: {mcp_saved}");
818 println!(" Last Updated: {updated}");
819 }
820 } else {
821 println!();
822 println!(
823 "MCP Session Cache: no data yet (start a session with your AI editor)"
824 );
825 }
826 }
827 }
828 Some("invalidate") => {
829 if args.len() < 2 {
830 eprintln!("Usage: lean-ctx cache invalidate <path>");
831 std::process::exit(1);
832 }
833 cli_cache::invalidate(&args[1]);
834 println!("Invalidated cache for {}", args[1]);
835 }
836 Some("prune") => {
837 let bm25 = prune_bm25_caches();
838 let graph = prune_graph_caches();
839 let archive_before = crate::core::archive::disk_usage_bytes()
842 + crate::core::archive_fts::db_size_bytes();
843 let archive_removed = crate::core::archive::cleanup();
844 let _ = crate::core::archive_fts::enforce_cap();
845 let archive_after = crate::core::archive::disk_usage_bytes()
846 + crate::core::archive_fts::db_size_bytes();
847 let archive_freed = archive_before.saturating_sub(archive_after);
848
849 let orphans = crate::core::knowledge::maintenance::prune_orphaned_stores();
853
854 let removed = bm25.removed + graph.removed + archive_removed + orphans.removed as u32;
855 let freed =
856 bm25.bytes_freed + graph.bytes_freed + archive_freed + orphans.reclaimed_bytes;
857 println!(
858 "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {}, archive: {}, orphaned stores: {})",
859 removed,
860 freed as f64 / 1_048_576.0,
861 bm25.removed,
862 graph.removed,
863 archive_removed,
864 orphans.removed,
865 );
866 }
867 _ => {
868 let (hits, reads, entries) = cli_cache::stats();
869 let rate = if reads > 0 {
870 (hits as f64 / reads as f64 * 100.0).round() as u32
871 } else {
872 0
873 };
874 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
875 println!();
876 println!("Subcommands:");
877 println!(" cache stats Show detailed stats");
878 println!(" cache clear Clear all cached entries");
879 println!(" cache reset Reset all cache (or --project for current project only)");
880 println!(" cache invalidate Remove specific file from cache");
881 println!(
882 " cache prune Reclaim BM25 + graph indexes, archive, and orphaned knowledge stores"
883 );
884 }
885 }
886}
887
888pub struct PruneResult {
889 pub scanned: u32,
890 pub removed: u32,
891 pub bytes_freed: u64,
892}
893
894pub fn prune_bm25_caches() -> PruneResult {
895 let mut result = PruneResult {
896 scanned: 0,
897 removed: 0,
898 bytes_freed: 0,
899 };
900
901 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
902 return result;
903 };
904 let vectors_dir = data_dir.join("vectors");
905 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
906 return result;
907 };
908
909 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
910
911 for entry in entries.flatten() {
912 let dir = entry.path();
913 if !dir.is_dir() {
914 continue;
915 }
916 result.scanned += 1;
917
918 for q_name in &[
919 "bm25_index.json.quarantined",
920 "bm25_index.bin.quarantined",
921 "bm25_index.bin.zst.quarantined",
922 ] {
923 let quarantined = dir.join(q_name);
924 if quarantined.exists() {
925 if let Ok(meta) = std::fs::metadata(&quarantined) {
926 result.bytes_freed += meta.len();
927 }
928 let _ = std::fs::remove_file(&quarantined);
929 result.removed += 1;
930 println!(" Removed quarantined: {}", quarantined.display());
931 }
932 }
933
934 let index_path = if dir.join("bm25_index.bin.zst").exists() {
935 dir.join("bm25_index.bin.zst")
936 } else if dir.join("bm25_index.bin").exists() {
937 dir.join("bm25_index.bin")
938 } else {
939 dir.join("bm25_index.json")
940 };
941 if let Ok(meta) = std::fs::metadata(&index_path)
942 && meta.len() > max_bytes
943 {
944 result.bytes_freed += meta.len();
945 let _ = std::fs::remove_file(&index_path);
946 result.removed += 1;
947 println!(
948 " Removed oversized ({:.1} MB): {}",
949 meta.len() as f64 / 1_048_576.0,
950 index_path.display()
951 );
952 }
953
954 let marker = dir.join("project_root.txt");
955 if let Ok(root_str) = std::fs::read_to_string(&marker) {
956 let root_path = std::path::Path::new(root_str.trim());
957 if !root_path.exists() {
958 let freed = dir_size(&dir);
959 result.bytes_freed += freed;
960 let _ = std::fs::remove_dir_all(&dir);
961 result.removed += 1;
962 println!(
963 " Removed orphaned ({:.1} MB, project gone: {}): {}",
964 freed as f64 / 1_048_576.0,
965 root_str.trim(),
966 dir.display()
967 );
968 }
969 }
970 }
971
972 result
973}
974
975pub fn prune_graph_caches() -> PruneResult {
976 let mut result = PruneResult {
977 scanned: 0,
978 removed: 0,
979 bytes_freed: 0,
980 };
981
982 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
983 return result;
984 };
985 let graphs_dir = data_dir.join("graphs");
986 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
987 return result;
988 };
989
990 for entry in entries.flatten() {
991 let dir = entry.path();
992 if !dir.is_dir() {
993 continue;
994 }
995 result.scanned += 1;
996
997 let meta_file = dir.join("graph.meta.json");
1001 let db_file = dir.join("graph.db");
1002 if !meta_file.exists() && !db_file.exists() {
1003 continue;
1004 }
1005
1006 let root_from_meta = try_read_project_root_from_graph(&meta_file);
1007 if let Some(root) = root_from_meta
1008 && !root.is_empty()
1009 && !std::path::Path::new(&root).exists()
1010 {
1011 let freed = dir_size(&dir);
1012 result.bytes_freed += freed;
1013 let _ = std::fs::remove_dir_all(&dir);
1014 result.removed += 1;
1015 println!(
1016 " Removed orphaned graph ({:.1} MB, project gone: {}): {}",
1017 freed as f64 / 1_048_576.0,
1018 root,
1019 dir.display()
1020 );
1021 continue;
1022 }
1023
1024 if let Ok(meta) = std::fs::metadata(&db_file)
1028 && meta.len() > 100 * 1024 * 1024
1029 {
1030 let freed = dir_size(&dir);
1031 result.bytes_freed += freed;
1032 let _ = std::fs::remove_dir_all(&dir);
1033 result.removed += 1;
1034 println!(
1035 " Removed oversized graph ({:.1} MB): {}",
1036 freed as f64 / 1_048_576.0,
1037 dir.display()
1038 );
1039 }
1040 }
1041
1042 result
1043}
1044
1045fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
1048 let content = std::fs::read_to_string(path).ok()?;
1049 let val: serde_json::Value = serde_json::from_str(&content).ok()?;
1050 val.get("project_root")?.as_str().map(String::from)
1051}
1052
1053pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
1054# Full reference: https://leanctx.com/docs/configuration
1055# For all settings: lean-ctx config init --full
1056
1057# ── High-Level Knobs ─────────────────────────────────────────────────
1058# These auto-adjust advanced settings. Override individual values below
1059# only if you need fine-grained control.
1060
1061# Output style for the model's prose (not tool-output compression):
1062# off — no style guidance
1063# lite — plain-English concise (default; readable, still token-saving)
1064# standard / max — denser symbolic "power modes" (opt-in)
1065compression_level = "lite"
1066
1067# RAM/feature trade-off: low | balanced | performance
1068memory_profile = "balanced"
1069
1070# Maximum % of system RAM lean-ctx may use (1-50)
1071max_ram_percent = 5
1072
1073# Total disk budget in MB (0 = use individual limits).
1074# Distributes proportionally: archive ~25%, BM25 cache ~10%.
1075# max_disk_mb = 2000
1076
1077# Auto-purge data older than N days (0 = disabled).
1078# Flows into archive.max_age_hours.
1079# max_staleness_days = 30
1080
1081# Explicit project paths to scan/index (default: auto-detect).
1082# [ide_paths]
1083# cursor = ["/home/user/projects/app1"]
1084
1085# ── Proxy ────────────────────────────────────────────────────────────
1086# proxy_enabled = false
1087# proxy_port = 3128
1088"#;
1089
1090fn write_simplified_config() -> Result<String, String> {
1091 let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
1092 if let Some(dir) = path.parent() {
1093 std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
1094 }
1095 std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
1096 Ok(path.to_string_lossy().to_string())
1097}
1098
1099fn cmd_show_effective() {
1100 let cfg = config::Config::load();
1101 let compression = config::CompressionLevel::effective(&cfg);
1102 let policy = cfg.memory_policy_effective().unwrap_or_default();
1103
1104 println!("╭─── Simplified (high-level) ───────────────────────────────╮");
1105 println!(
1106 "│ compression_level = {:10} {}",
1107 format!("{compression:?}"),
1108 source_hint(
1109 "LEAN_CTX_COMPRESSION",
1110 cfg.compression_level != config::CompressionLevel::Off
1111 )
1112 );
1113 println!(
1114 "│ max_disk_mb = {:10} {}",
1115 cfg.max_disk_mb_effective(),
1116 source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
1117 );
1118 println!(
1119 "│ max_ram_percent = {:10} {}",
1120 cfg.max_ram_percent,
1121 source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
1122 );
1123 println!(
1124 "│ max_staleness_days = {:10} {}",
1125 cfg.max_staleness_days_effective(),
1126 source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
1127 );
1128 println!(
1129 "│ memory_profile = {:10} {}",
1130 format!("{:?}", cfg.memory_profile),
1131 source_hint("LEAN_CTX_MEMORY_PROFILE", false)
1132 );
1133 println!("╰────────────────────────────────────────────────────────────╯");
1134
1135 println!();
1136 println!("╭─── Derived effective limits ────────────────────────────────╮");
1137 println!(
1138 "│ archive_max_disk_mb = {:>6} MB",
1139 cfg.archive_max_disk_mb_effective()
1140 );
1141 println!(
1142 "│ bm25_max_cache_mb = {:>6} MB",
1143 cfg.bm25_max_cache_mb_effective()
1144 );
1145 println!(
1146 "│ archive_max_age_hours = {:>6} h",
1147 cfg.archive_max_age_hours_effective()
1148 );
1149 println!(
1150 "│ graph_index_max_files = {:>6}",
1151 cfg.graph_index_max_files
1152 );
1153 println!("│");
1154 println!(
1155 "│ memory.knowledge.max_facts = {:>6}",
1156 policy.knowledge.max_facts
1157 );
1158 println!(
1159 "│ memory.knowledge.max_patterns = {:>6}",
1160 policy.knowledge.max_patterns
1161 );
1162 println!(
1163 "│ memory.episodic.max_episodes = {:>6}",
1164 policy.episodic.max_episodes
1165 );
1166 println!(
1167 "│ memory.procedural.max_procedures = {:>4}",
1168 policy.procedural.max_procedures
1169 );
1170 println!("╰────────────────────────────────────────────────────────────╯");
1171
1172 if cfg.max_disk_mb_effective() > 0 {
1173 println!();
1174 println!(
1175 " ℹ max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
1176 cfg.max_disk_mb_effective(),
1177 (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
1178 );
1179 }
1180}
1181
1182fn source_hint(env_var: &str, config_set: bool) -> &'static str {
1183 if std::env::var(env_var).is_ok() {
1184 "← env"
1185 } else if config_set {
1186 "← config"
1187 } else {
1188 "← default"
1189 }
1190}
1191
1192fn dir_size(path: &std::path::Path) -> u64 {
1193 let mut total = 0u64;
1194 if let Ok(entries) = std::fs::read_dir(path) {
1195 for entry in entries.flatten() {
1196 let p = entry.path();
1197 if p.is_file() {
1198 total += std::fs::metadata(&p).map_or(0, |m| m.len());
1199 } else if p.is_dir() {
1200 total += dir_size(&p);
1201 }
1202 }
1203 }
1204 total
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210
1211 fn merged_max_ram(cfg: &config::Config, existing: &str) -> u8 {
1215 let dir = tempfile::tempdir().unwrap();
1216 let path = dir.path().join("config.toml");
1217 std::fs::write(&path, existing).unwrap();
1218 let new_content = toml::to_string_pretty(cfg).unwrap();
1219 let baseline = toml::from_str::<config::Config>("").unwrap();
1220 let defaults = toml::to_string_pretty(&baseline).unwrap();
1221 crate::config_io::write_toml_preserving_minimal(&path, &new_content, &defaults).unwrap();
1222 let written = std::fs::read_to_string(&path).unwrap();
1223 toml::from_str::<config::Config>(&written)
1224 .unwrap()
1225 .max_ram_percent
1226 }
1227
1228 #[test]
1229 fn full_init_uses_existing_values_not_defaults() {
1230 let existing = "max_ram_percent = 30\ncompression_level = \"standard\"\n";
1231 let cfg = config_for_full_init(Some(existing)).expect("parse existing");
1232 assert_eq!(cfg.max_ram_percent, 30, "must keep the user's value, not 5");
1233 assert_eq!(cfg.compression_level, config::CompressionLevel::Standard);
1234 }
1235
1236 #[test]
1237 fn full_init_falls_back_to_defaults_on_fresh_install() {
1238 let cfg = config_for_full_init(None).expect("default");
1239 assert_eq!(
1240 cfg.max_ram_percent,
1241 config::Config::default().max_ram_percent
1242 );
1243 let cfg_empty = config_for_full_init(Some(" \n")).expect("blank -> default");
1244 assert_eq!(
1245 cfg_empty.max_ram_percent,
1246 config::Config::default().max_ram_percent
1247 );
1248 }
1249
1250 #[test]
1251 fn full_init_refuses_unparseable_config() {
1252 assert!(config_for_full_init(Some("max_ram_percent = = =")).is_err());
1253 }
1254
1255 #[test]
1257 fn full_init_preserves_value_through_save_merge() {
1258 let existing = "max_ram_percent = 30\n";
1259 let cfg = config_for_full_init(Some(existing)).unwrap();
1260 assert_eq!(
1261 merged_max_ram(&cfg, existing),
1262 30,
1263 "user value must survive `config init --full`"
1264 );
1265 }
1266
1267 #[test]
1271 fn default_seed_resets_value_root_cause_marker() {
1272 let existing = "max_ram_percent = 30\n";
1273 assert_eq!(
1274 merged_max_ram(&config::Config::default(), existing),
1275 5,
1276 "default seed resets to 5 — the #443 regression we fixed"
1277 );
1278 }
1279}