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