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 let default = config::Config::default();
20 match default.save() {
21 Ok(()) => {
22 let path = config::Config::path().map_or_else(
23 || "~/.lean-ctx/config.toml".to_string(),
24 |p| p.to_string_lossy().to_string(),
25 );
26 println!("Created full config at {path}");
27 }
28 Err(e) => eprintln!("Error: {e}"),
29 }
30 } else {
31 match write_simplified_config() {
32 Ok(path) => println!("Created simplified config at {path}"),
33 Err(e) => eprintln!("Error: {e}"),
34 }
35 }
36 }
37 "set" => {
38 if args.len() < 3 {
39 eprintln!("Usage: lean-ctx config set <key> <value>");
40 std::process::exit(1);
41 }
42 let key = &args[1];
43 let val = &args[2];
44
45 match key.as_str() {
48 "theme" if theme::from_preset(val).is_none() && val != "custom" => {
49 eprintln!(
50 "Unknown theme '{val}'. Available: {}",
51 theme::PRESET_NAMES.join(", ")
52 );
53 std::process::exit(1);
54 }
55 "tee_on_error" | "tee_mode" => {
56 let normalized = match val.as_str() {
57 "true" => "failures",
58 "false" => "never",
59 other => other,
60 };
61 match config::setter::set_by_key("tee_mode", normalized) {
62 Ok(_) => println!("Updated {key} = {val}"),
63 Err(e) => {
64 eprintln!("{e}");
65 std::process::exit(1);
66 }
67 }
68 return;
69 }
70 "project_root" => {
71 let path = std::path::Path::new(val.as_str());
72 if !path.exists() || !path.is_dir() {
73 eprintln!("Error: '{val}' is not an existing directory.");
74 std::process::exit(1);
75 }
76 }
77 "proxy.anthropic_upstream" | "proxy.openai_upstream" | "proxy.gemini_upstream" => {
78 let normalized = normalize_optional_upstream(val);
79 let effective = normalized.as_deref().unwrap_or("");
80 match config::setter::set_by_key(key, effective) {
81 Ok(_) => println!("Updated {key} = {val}"),
82 Err(e) => {
83 eprintln!("{e}");
84 std::process::exit(1);
85 }
86 }
87 return;
88 }
89 _ => {}
90 }
91
92 match config::setter::set_by_key(key, val) {
94 Ok(_) => println!("Updated {key} = {val}"),
95 Err(e) => {
96 eprintln!("{e}");
97 std::process::exit(1);
98 }
99 }
100 }
101 "schema" => {
102 let schema = config::schema::ConfigSchema::generate();
103 println!(
104 "{}",
105 serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
106 );
107 }
108 "validate" => {
109 cmd_validate();
110 }
111 "show" | "effective" => {
112 cmd_show_effective();
113 }
114 "apply" | "reload" => {
115 cmd_apply();
116 }
117 _ => {
118 eprintln!("Usage: lean-ctx config [init|set|show|schema|validate|apply]");
119 std::process::exit(1);
120 }
121 }
122}
123
124fn cmd_apply() {
125 use crate::daemon;
126 use crate::ipc;
127
128 println!("Applying config changes…");
129
130 println!("\n[1/4] Validating config…");
132 let schema = config::schema::ConfigSchema::generate();
133 let known = schema.known_keys();
134 let cfg = config::Config::load();
135
136 if let Some(path) = config::Config::path() {
137 if path.exists() {
138 if let Ok(raw) = std::fs::read_to_string(&path) {
139 if let Ok(table) = raw.parse::<toml::Table>() {
140 let mut user_keys = Vec::new();
141 fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
142 for (k, v) in table {
143 let full = if prefix.is_empty() {
144 k.clone()
145 } else {
146 format!("{prefix}.{k}")
147 };
148 if let toml::Value::Table(sub) = v {
149 collect_flat(sub, &full, out);
150 } else {
151 out.push(full);
152 }
153 }
154 }
155 collect_flat(&table, "", &mut user_keys);
156 let warnings: Vec<_> = user_keys
157 .iter()
158 .filter(|uk| {
159 !known.contains(uk)
160 && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
161 })
162 .collect();
163 if warnings.is_empty() {
164 println!(" ✓ All config keys valid.");
165 } else {
166 for w in &warnings {
167 eprintln!(" [WARN] Unknown key: {w}");
168 }
169 eprintln!(
170 " {} unknown key(s) found. Continuing anyway…",
171 warnings.len()
172 );
173 }
174 }
175 }
176 }
177 }
178
179 println!("\n[2/4] Restarting processes…");
181 crate::proxy_autostart::stop();
182
183 if let Err(e) = daemon::stop_daemon() {
184 eprintln!(" Warning: daemon stop: {e}");
185 }
186
187 let orphans = ipc::process::kill_all_by_name("lean-ctx");
188 if orphans > 0 {
189 println!(" Terminated {orphans} orphan process(es).");
190 }
191
192 std::thread::sleep(std::time::Duration::from_millis(500));
193
194 let remaining = ipc::process::find_pids_by_name("lean-ctx");
195 if !remaining.is_empty() {
196 for &pid in &remaining {
197 let _ = ipc::process::force_kill(pid);
198 }
199 std::thread::sleep(std::time::Duration::from_millis(300));
200 }
201
202 daemon::cleanup_daemon_files();
203 crate::proxy_autostart::start();
204
205 match daemon::start_daemon(&[]) {
206 Ok(()) => println!(" ✓ Daemon restarted."),
207 Err(e) => {
208 eprintln!(" ✗ Daemon start failed: {e}");
209 std::process::exit(1);
210 }
211 }
212
213 println!("\n[3/4] Running safety checks…");
215 println!(" RAM guard: max {}% system", cfg.max_ram_percent);
216
217 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
218 let sessions_dir = data_dir.join("sessions");
219 let session_count = std::fs::read_dir(&sessions_dir)
220 .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
221 println!(" Sessions dir: {session_count} files");
222 }
223
224 println!("\n[4/4] Config applied successfully.");
226 println!(" Theme: {}", cfg.theme);
227 println!(" Ultra compact: {}", cfg.ultra_compact);
228 println!(" Checkpoint: every {} calls", cfg.checkpoint_interval);
229 if let Some(ref root) = cfg.project_root {
230 println!(" Project root: {root}");
231 }
232}
233
234fn cmd_validate() {
235 let schema = config::schema::ConfigSchema::generate();
236 let known = schema.known_keys();
237
238 let path = match config::Config::path() {
239 Some(p) if p.exists() => p,
240 _ => {
241 println!("[OK] No config.toml found — using defaults.");
242 return;
243 }
244 };
245
246 let raw = match std::fs::read_to_string(&path) {
247 Ok(s) => s,
248 Err(e) => {
249 eprintln!("[ERROR] Cannot read {}: {e}", path.display());
250 std::process::exit(1);
251 }
252 };
253
254 let table: toml::Table = match raw.parse() {
255 Ok(t) => t,
256 Err(e) => {
257 eprintln!("[ERROR] Invalid TOML: {e}");
258 std::process::exit(1);
259 }
260 };
261
262 let mut warnings = 0u32;
263 let mut validated = 0u32;
264
265 fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
266 for (k, v) in table {
267 let full = if prefix.is_empty() {
268 k.clone()
269 } else {
270 format!("{prefix}.{k}")
271 };
272 match v {
273 toml::Value::Table(sub) => collect_keys(sub, &full, out),
274 toml::Value::Array(arr) => {
275 out.push(full.clone());
276 for item in arr {
277 if let toml::Value::Table(sub) = item {
278 for sk in sub.keys() {
279 out.push(format!("{full}[].{sk}"));
280 }
281 }
282 }
283 }
284 _ => out.push(full),
285 }
286 }
287 }
288
289 let mut user_keys = Vec::new();
290 collect_keys(&table, "", &mut user_keys);
291
292 for uk in &user_keys {
293 let base = uk.split("[].").next().unwrap_or(uk);
294 let field = uk.rsplit("[].").next().unwrap_or("");
295 let check_key = if uk.contains("[].") {
296 format!("{base}.{field}")
297 } else {
298 uk.clone()
299 };
300
301 if known.contains(&check_key)
302 || known
303 .iter()
304 .any(|k| check_key.starts_with(&format!("{k}.")))
305 {
306 validated += 1;
307 } else {
308 warnings += 1;
309 let suggestion = find_closest(&check_key, &known);
310 if let Some(sug) = suggestion {
311 eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
312 } else {
313 eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
314 }
315 }
316 }
317
318 let cfg = config::Config::load();
319 let budget = cfg.max_disk_mb_effective();
320 if budget > 0 {
321 let explicit_archive = cfg.archive.max_disk_mb;
322 let explicit_bm25 = cfg.bm25_max_cache_mb;
323 let sum = explicit_archive + explicit_bm25;
324 if sum > budget {
325 warnings += 1;
326 println!(
327 " ⚠ max_disk_mb={budget} but archive.max_disk_mb({explicit_archive}) + bm25_max_cache_mb({explicit_bm25}) = {sum} exceeds budget"
328 );
329 }
330 }
331
332 let total = validated + warnings;
333 if warnings == 0 {
334 println!(
335 "[OK] All {total} keys validated successfully ({}).",
336 path.display()
337 );
338 } else {
339 println!(
340 "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
341 path.display()
342 );
343 std::process::exit(1);
344 }
345}
346
347fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
348 let mut best: Option<(usize, &str)> = None;
349 for candidate in haystack {
350 let d = levenshtein(needle, candidate);
351 if d <= 3 && (best.is_none() || d < best.unwrap().0) {
352 best = Some((d, candidate));
353 }
354 }
355 if best.is_some() {
356 return best.map(|(_, s)| s.to_string());
357 }
358 let leaf = needle.rsplit('.').next().unwrap_or(needle);
359 let mut leaf_best: Option<(usize, &str)> = None;
360 for candidate in haystack {
361 let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
362 let d = levenshtein(leaf, cand_leaf);
363 if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
364 leaf_best = Some((d, candidate));
365 }
366 }
367 leaf_best.map(|(_, s)| s.to_string())
368}
369
370fn levenshtein(a: &str, b: &str) -> usize {
371 let a: Vec<char> = a.chars().collect();
372 let b: Vec<char> = b.chars().collect();
373 let (m, n) = (a.len(), b.len());
374 let mut dp = vec![vec![0usize; n + 1]; m + 1];
375 for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
376 row[0] = i;
377 }
378 for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
379 *val = j;
380 }
381 for i in 1..=m {
382 for j in 1..=n {
383 let cost = usize::from(a[i - 1] != b[j - 1]);
384 dp[i][j] = (dp[i - 1][j] + 1)
385 .min(dp[i][j - 1] + 1)
386 .min(dp[i - 1][j - 1] + cost);
387 }
388 }
389 dp[m][n]
390}
391
392fn normalize_optional_upstream(value: &str) -> Option<String> {
393 use crate::core::config::normalize_url_opt;
394 let trimmed = value.trim();
395 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
396 None
397 } else {
398 normalize_url_opt(trimmed)
399 }
400}
401
402pub fn cmd_benchmark(args: &[String]) {
403 use crate::core::benchmark;
404 use crate::core::benchmark_compare;
405
406 let action = args.first().map_or("run", std::string::String::as_str);
407
408 match action {
409 "--help" | "-h" => {
410 println!("Usage: lean-ctx benchmark run [path] [--json]");
411 println!(" lean-ctx benchmark report [path]");
412 println!(" lean-ctx benchmark eval [path] [--json]");
413 println!(" lean-ctx benchmark compare [--repo path] [--output file.md]");
414 }
415 "eval" => {
416 let path = args.get(1).map_or(".", std::string::String::as_str);
417 let is_json = args.iter().any(|a| a == "--json");
418 let root = std::path::Path::new(path);
419
420 let index = crate::core::bm25_index::BM25Index::build_from_directory(root);
421 let cfg = crate::core::hybrid_search::HybridConfig::from_config();
422 let queries = crate::core::eval_harness::generate_self_eval(&index, 50);
423
424 if queries.is_empty() {
425 eprintln!("No symbols found — cannot generate eval queries.");
426 std::process::exit(1);
427 }
428
429 let scorecard = crate::core::eval_harness::run_eval(root, &queries, &index, &cfg);
430 if is_json {
431 if let Ok(json) = serde_json::to_string_pretty(&scorecard) {
432 println!("{json}");
433 }
434 } else {
435 print!("{scorecard}");
436 }
437 }
438 "run" => {
439 let path = args.get(1).map_or(".", std::string::String::as_str);
440 let is_json = args.iter().any(|a| a == "--json");
441
442 let result = benchmark::run_project_benchmark(path);
443 if is_json {
444 println!("{}", benchmark::format_json(&result));
445 } else {
446 println!("{}", benchmark::format_terminal(&result));
447 }
448 }
449 "report" => {
450 let path = args.get(1).map_or(".", std::string::String::as_str);
451 let result = benchmark::run_project_benchmark(path);
452 println!("{}", benchmark::format_markdown(&result));
453 }
454 "compare" => {
455 let repo = parse_flag_value(args, "--repo").unwrap_or_else(|| ".".to_string());
456 let output = parse_flag_value(args, "--output");
457
458 let root = std::path::Path::new(&repo);
459 if !root.exists() {
460 eprintln!("Repository path does not exist: {repo}");
461 std::process::exit(1);
462 }
463
464 let report = benchmark_compare::run_compare(root, output.as_deref());
465
466 println!("{}", benchmark_compare::report::generate_terminal(&report));
467
468 if output.is_none() {
469 eprintln!("Tip: use --output BENCHMARKS.md to save the full markdown report");
470 }
471 }
472 _ => {
473 if std::path::Path::new(action).exists() {
474 let result = benchmark::run_project_benchmark(action);
475 println!("{}", benchmark::format_terminal(&result));
476 } else {
477 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
478 eprintln!(" lean-ctx benchmark report [path]");
479 eprintln!(" lean-ctx benchmark compare [--repo path] [--output file.md]");
480 std::process::exit(1);
481 }
482 }
483 }
484}
485
486fn parse_flag_value(args: &[String], flag: &str) -> Option<String> {
487 args.iter()
488 .position(|a| a == flag)
489 .and_then(|i| args.get(i + 1))
490 .cloned()
491}
492
493pub fn cmd_stats(args: &[String]) {
494 match args.first().map(std::string::String::as_str) {
495 Some("reset-cep") => {
496 crate::core::stats::reset_cep();
497 println!("CEP stats reset. Shell hook data preserved.");
498 }
499 Some("json") => {
500 let store = crate::core::stats::load();
501 println!(
502 "{}",
503 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
504 );
505 }
506 _ => {
507 let store = crate::core::stats::load();
508 let input_saved = store
509 .total_input_tokens
510 .saturating_sub(store.total_output_tokens);
511 let pct = if store.total_input_tokens > 0 {
512 input_saved as f64 / store.total_input_tokens as f64 * 100.0
513 } else {
514 0.0
515 };
516 println!("Commands: {}", store.total_commands);
517 println!("Input: {} tokens", store.total_input_tokens);
518 println!("Output: {} tokens", store.total_output_tokens);
519 println!("Saved: {input_saved} tokens ({pct:.1}%)");
520 println!();
521 println!("CEP sessions: {}", store.cep.sessions);
522 println!(
523 "CEP tokens: {} → {}",
524 store.cep.total_tokens_original, store.cep.total_tokens_compressed
525 );
526 println!();
527 println!("Subcommands: stats reset-cep | stats json");
528 }
529 }
530}
531
532pub fn cmd_cache(args: &[String]) {
533 use crate::core::cli_cache;
534 match args.first().map(std::string::String::as_str) {
535 Some("clear") => {
536 let count = cli_cache::clear();
537 println!("Cleared {count} cached entries.");
538 }
539 Some("reset") => {
540 let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
541 if project_flag {
542 let root =
543 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
544 if let Some(root) = root {
545 let count = cli_cache::clear_project(&root);
546 println!("Reset {count} cache entries for project: {root}");
547 } else {
548 eprintln!("No active project root found. Start a session first.");
549 std::process::exit(1);
550 }
551 } else {
552 let count = cli_cache::clear();
553 println!("Reset all {count} cache entries.");
554 }
555 }
556 Some("stats") => {
557 let (hits, reads, entries) = cli_cache::stats();
558 let rate = if reads > 0 {
559 (hits as f64 / reads as f64 * 100.0).round() as u32
560 } else {
561 0
562 };
563 println!("CLI Cache Stats (lean-ctx read / lean-ctx grep):");
564 println!(" Entries: {entries}");
565 println!(" Reads: {reads}");
566 println!(" Hits: {hits}");
567 println!(" Hit Rate: {rate}%");
568
569 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
570 let live_path = dir.join("mcp-live.json");
571 if let Ok(content) = std::fs::read_to_string(&live_path) {
572 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
573 let mcp_reads = val
574 .get("total_reads")
575 .and_then(serde_json::Value::as_u64)
576 .unwrap_or(0);
577 let mcp_hits = val
578 .get("cache_hits")
579 .and_then(serde_json::Value::as_u64)
580 .unwrap_or(0);
581 let mcp_saved = val
582 .get("tokens_saved")
583 .and_then(serde_json::Value::as_u64)
584 .unwrap_or(0);
585 let mcp_rate = if mcp_reads > 0 {
586 (mcp_hits as f64 / mcp_reads as f64 * 100.0).round() as u32
587 } else {
588 0
589 };
590 let updated = val
591 .get("updated_at")
592 .and_then(serde_json::Value::as_str)
593 .unwrap_or("unknown");
594 println!();
595 println!("MCP Session Cache (ctx_read via AI editor):");
596 println!(" Reads: {mcp_reads}");
597 println!(" Hits: {mcp_hits}");
598 println!(" Hit Rate: {mcp_rate}%");
599 println!(" Tokens Saved: {mcp_saved}");
600 println!(" Last Updated: {updated}");
601 }
602 } else {
603 println!();
604 println!(
605 "MCP Session Cache: no data yet (start a session with your AI editor)"
606 );
607 }
608 }
609 }
610 Some("invalidate") => {
611 if args.len() < 2 {
612 eprintln!("Usage: lean-ctx cache invalidate <path>");
613 std::process::exit(1);
614 }
615 cli_cache::invalidate(&args[1]);
616 println!("Invalidated cache for {}", args[1]);
617 }
618 Some("prune") => {
619 let bm25 = prune_bm25_caches();
620 let graph = prune_graph_caches();
621 let removed = bm25.removed + graph.removed;
622 let freed = bm25.bytes_freed + graph.bytes_freed;
623 println!(
624 "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {})",
625 removed,
626 freed as f64 / 1_048_576.0,
627 bm25.removed,
628 graph.removed,
629 );
630 }
631 _ => {
632 let (hits, reads, entries) = cli_cache::stats();
633 let rate = if reads > 0 {
634 (hits as f64 / reads as f64 * 100.0).round() as u32
635 } else {
636 0
637 };
638 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
639 println!();
640 println!("Subcommands:");
641 println!(" cache stats Show detailed stats");
642 println!(" cache clear Clear all cached entries");
643 println!(" cache reset Reset all cache (or --project for current project only)");
644 println!(" cache invalidate Remove specific file from cache");
645 println!(
646 " cache prune Remove oversized, quarantined, and orphaned indexes (BM25 + graphs)"
647 );
648 }
649 }
650}
651
652pub struct PruneResult {
653 pub scanned: u32,
654 pub removed: u32,
655 pub bytes_freed: u64,
656}
657
658pub fn prune_bm25_caches() -> PruneResult {
659 let mut result = PruneResult {
660 scanned: 0,
661 removed: 0,
662 bytes_freed: 0,
663 };
664
665 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
666 return result;
667 };
668 let vectors_dir = data_dir.join("vectors");
669 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
670 return result;
671 };
672
673 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb_effective() * 1024 * 1024;
674
675 for entry in entries.flatten() {
676 let dir = entry.path();
677 if !dir.is_dir() {
678 continue;
679 }
680 result.scanned += 1;
681
682 for q_name in &[
683 "bm25_index.json.quarantined",
684 "bm25_index.bin.quarantined",
685 "bm25_index.bin.zst.quarantined",
686 ] {
687 let quarantined = dir.join(q_name);
688 if quarantined.exists() {
689 if let Ok(meta) = std::fs::metadata(&quarantined) {
690 result.bytes_freed += meta.len();
691 }
692 let _ = std::fs::remove_file(&quarantined);
693 result.removed += 1;
694 println!(" Removed quarantined: {}", quarantined.display());
695 }
696 }
697
698 let index_path = if dir.join("bm25_index.bin.zst").exists() {
699 dir.join("bm25_index.bin.zst")
700 } else if dir.join("bm25_index.bin").exists() {
701 dir.join("bm25_index.bin")
702 } else {
703 dir.join("bm25_index.json")
704 };
705 if let Ok(meta) = std::fs::metadata(&index_path) {
706 if meta.len() > max_bytes {
707 result.bytes_freed += meta.len();
708 let _ = std::fs::remove_file(&index_path);
709 result.removed += 1;
710 println!(
711 " Removed oversized ({:.1} MB): {}",
712 meta.len() as f64 / 1_048_576.0,
713 index_path.display()
714 );
715 }
716 }
717
718 let marker = dir.join("project_root.txt");
719 if let Ok(root_str) = std::fs::read_to_string(&marker) {
720 let root_path = std::path::Path::new(root_str.trim());
721 if !root_path.exists() {
722 let freed = dir_size(&dir);
723 result.bytes_freed += freed;
724 let _ = std::fs::remove_dir_all(&dir);
725 result.removed += 1;
726 println!(
727 " Removed orphaned ({:.1} MB, project gone: {}): {}",
728 freed as f64 / 1_048_576.0,
729 root_str.trim(),
730 dir.display()
731 );
732 }
733 }
734 }
735
736 result
737}
738
739pub fn prune_graph_caches() -> PruneResult {
740 let mut result = PruneResult {
741 scanned: 0,
742 removed: 0,
743 bytes_freed: 0,
744 };
745
746 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
747 return result;
748 };
749 let graphs_dir = data_dir.join("graphs");
750 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
751 return result;
752 };
753
754 for entry in entries.flatten() {
755 let dir = entry.path();
756 if !dir.is_dir() {
757 continue;
758 }
759 result.scanned += 1;
760
761 let index_path = dir.join("index.json.zst");
762 let index_json = dir.join("index.json");
763
764 let has_index = index_path.exists() || index_json.exists();
765 if !has_index {
766 continue;
767 }
768
769 let idx_file = if index_path.exists() {
770 &index_path
771 } else {
772 &index_json
773 };
774
775 let root_from_index = try_read_project_root_from_graph(idx_file);
776 if let Some(root) = root_from_index {
777 if !root.is_empty() && !std::path::Path::new(&root).exists() {
778 let freed = dir_size(&dir);
779 result.bytes_freed += freed;
780 let _ = std::fs::remove_dir_all(&dir);
781 result.removed += 1;
782 println!(
783 " Removed orphaned graph ({:.1} MB, project gone: {}): {}",
784 freed as f64 / 1_048_576.0,
785 root,
786 dir.display()
787 );
788 continue;
789 }
790 }
791
792 if let Ok(meta) = std::fs::metadata(idx_file) {
793 if meta.len() > 100 * 1024 * 1024 {
794 result.bytes_freed += meta.len();
795 let _ = std::fs::remove_file(idx_file);
796 result.removed += 1;
797 println!(
798 " Removed oversized graph ({:.1} MB): {}",
799 meta.len() as f64 / 1_048_576.0,
800 idx_file.display()
801 );
802 }
803 }
804 }
805
806 result
807}
808
809fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
810 let data = if path.extension().and_then(|e| e.to_str()) == Some("zst") {
811 let compressed = std::fs::read(path).ok()?;
812 zstd::decode_all(compressed.as_slice()).ok()?
813 } else {
814 std::fs::read(path).ok()?
815 };
816 let content = String::from_utf8(data).ok()?;
817 let val: serde_json::Value = serde_json::from_str(&content).ok()?;
818 val.get("project_root")?.as_str().map(String::from)
819}
820
821pub const SIMPLIFIED_TEMPLATE: &str = r#"# lean-ctx — Simplified Configuration
822# Full reference: https://leanctx.com/docs/configuration
823# For all settings: lean-ctx config init --full
824
825# ── High-Level Knobs ─────────────────────────────────────────────────
826# These auto-adjust advanced settings. Override individual values below
827# only if you need fine-grained control.
828
829# Output style for the model's prose (not tool-output compression):
830# off — no style guidance
831# lite — plain-English concise (default; readable, still token-saving)
832# standard / max — denser symbolic "power modes" (opt-in)
833compression_level = "lite"
834
835# RAM/feature trade-off: low | balanced | performance
836memory_profile = "balanced"
837
838# Maximum % of system RAM lean-ctx may use (1-50)
839max_ram_percent = 5
840
841# Total disk budget in MB (0 = use individual limits).
842# Distributes proportionally: archive ~25%, BM25 cache ~10%.
843# max_disk_mb = 2000
844
845# Auto-purge data older than N days (0 = disabled).
846# Flows into archive.max_age_hours.
847# max_staleness_days = 30
848
849# Explicit project paths to scan/index (default: auto-detect).
850# [ide_paths]
851# cursor = ["/home/user/projects/app1"]
852
853# ── Proxy ────────────────────────────────────────────────────────────
854# proxy_enabled = false
855# proxy_port = 3128
856"#;
857
858fn write_simplified_config() -> Result<String, String> {
859 let path = config::Config::path().ok_or_else(|| "Cannot determine config path".to_string())?;
860 if let Some(dir) = path.parent() {
861 std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
862 }
863 std::fs::write(&path, SIMPLIFIED_TEMPLATE).map_err(|e| format!("{e}"))?;
864 Ok(path.to_string_lossy().to_string())
865}
866
867fn cmd_show_effective() {
868 let cfg = config::Config::load();
869 let compression = config::CompressionLevel::effective(&cfg);
870 let policy = cfg.memory_policy_effective().unwrap_or_default();
871
872 println!("╭─── Simplified (high-level) ───────────────────────────────╮");
873 println!(
874 "│ compression_level = {:10} {}",
875 format!("{compression:?}"),
876 source_hint(
877 "LEAN_CTX_COMPRESSION",
878 cfg.compression_level != config::CompressionLevel::Off
879 )
880 );
881 println!(
882 "│ max_disk_mb = {:10} {}",
883 cfg.max_disk_mb_effective(),
884 source_hint("LEAN_CTX_MAX_DISK_MB", cfg.max_disk_mb > 0)
885 );
886 println!(
887 "│ max_ram_percent = {:10} {}",
888 cfg.max_ram_percent,
889 source_hint("LEAN_CTX_MAX_RAM_PERCENT", cfg.max_ram_percent != 5)
890 );
891 println!(
892 "│ max_staleness_days = {:10} {}",
893 cfg.max_staleness_days_effective(),
894 source_hint("LEAN_CTX_MAX_STALENESS_DAYS", cfg.max_staleness_days > 0)
895 );
896 println!(
897 "│ memory_profile = {:10} {}",
898 format!("{:?}", cfg.memory_profile),
899 source_hint("LEAN_CTX_MEMORY_PROFILE", false)
900 );
901 println!("╰────────────────────────────────────────────────────────────╯");
902
903 println!();
904 println!("╭─── Derived effective limits ────────────────────────────────╮");
905 println!(
906 "│ archive_max_disk_mb = {:>6} MB",
907 cfg.archive_max_disk_mb_effective()
908 );
909 println!(
910 "│ bm25_max_cache_mb = {:>6} MB",
911 cfg.bm25_max_cache_mb_effective()
912 );
913 println!(
914 "│ archive_max_age_hours = {:>6} h",
915 cfg.archive_max_age_hours_effective()
916 );
917 println!(
918 "│ graph_index_max_files = {:>6}",
919 cfg.graph_index_max_files
920 );
921 println!("│");
922 println!(
923 "│ memory.knowledge.max_facts = {:>6}",
924 policy.knowledge.max_facts
925 );
926 println!(
927 "│ memory.knowledge.max_patterns = {:>6}",
928 policy.knowledge.max_patterns
929 );
930 println!(
931 "│ memory.episodic.max_episodes = {:>6}",
932 policy.episodic.max_episodes
933 );
934 println!(
935 "│ memory.procedural.max_procedures = {:>4}",
936 policy.procedural.max_procedures
937 );
938 println!("╰────────────────────────────────────────────────────────────╯");
939
940 if cfg.max_disk_mb_effective() > 0 {
941 println!();
942 println!(
943 " ℹ max_disk_mb={} → limits scaled proportionally (factor: {:.1}x)",
944 cfg.max_disk_mb_effective(),
945 (cfg.max_disk_mb_effective() as f64 / 500.0).clamp(0.5, 10.0)
946 );
947 }
948}
949
950fn source_hint(env_var: &str, config_set: bool) -> &'static str {
951 if std::env::var(env_var).is_ok() {
952 "← env"
953 } else if config_set {
954 "← config"
955 } else {
956 "← default"
957 }
958}
959
960fn dir_size(path: &std::path::Path) -> u64 {
961 let mut total = 0u64;
962 if let Ok(entries) = std::fs::read_dir(path) {
963 for entry in entries.flatten() {
964 let p = entry.path();
965 if p.is_file() {
966 total += std::fs::metadata(&p).map_or(0, |m| m.len());
967 } else if p.is_dir() {
968 total += dir_size(&p);
969 }
970 }
971 }
972 total
973}