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