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