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 return;
10 }
11
12 match args[0].as_str() {
13 "init" | "create" => {
14 let default = config::Config::default();
15 match default.save() {
16 Ok(()) => {
17 let path = config::Config::path().map_or_else(
18 || "~/.lean-ctx/config.toml".to_string(),
19 |p| p.to_string_lossy().to_string(),
20 );
21 println!("Created default config at {path}");
22 }
23 Err(e) => eprintln!("Error: {e}"),
24 }
25 }
26 "set" => {
27 if args.len() < 3 {
28 eprintln!("Usage: lean-ctx config set <key> <value>");
29 std::process::exit(1);
30 }
31 let mut cfg = cfg;
32 let key = &args[1];
33 let val = &args[2];
34 match key.as_str() {
35 "ultra_compact" => cfg.ultra_compact = val == "true",
36 "tee_on_error" | "tee_mode" => {
37 cfg.tee_mode = match val.as_str() {
38 "true" | "failures" => config::TeeMode::Failures,
39 "highcompression" | "high_compression" => config::TeeMode::HighCompression,
40 "always" => config::TeeMode::Always,
41 "false" | "never" => config::TeeMode::Never,
42 _ => {
43 eprintln!(
44 "Valid tee_mode values: always, highcompression, failures, never"
45 );
46 std::process::exit(1);
47 }
48 };
49 }
50 "checkpoint_interval" => {
51 cfg.checkpoint_interval = val.parse().unwrap_or(15);
52 }
53 "theme" => {
54 if theme::from_preset(val).is_some() || val == "custom" {
55 cfg.theme.clone_from(val);
56 } else {
57 eprintln!(
58 "Unknown theme '{val}'. Available: {}",
59 theme::PRESET_NAMES.join(", ")
60 );
61 std::process::exit(1);
62 }
63 }
64 "slow_command_threshold_ms" => {
65 cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
66 }
67 "passthrough_urls" => {
68 cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
69 }
70 "excluded_commands" => {
71 cfg.excluded_commands = val
72 .split(',')
73 .map(|s| s.trim().to_string())
74 .filter(|s| !s.is_empty())
75 .collect();
76 }
77 "rules_scope" => match val.as_str() {
78 "global" | "project" | "both" => {
79 cfg.rules_scope = Some(val.clone());
80 }
81 _ => {
82 eprintln!("Valid rules_scope values: global, project, both");
83 std::process::exit(1);
84 }
85 },
86 "project_root" => {
87 let path = std::path::Path::new(val.as_str());
88 if !path.exists() || !path.is_dir() {
89 eprintln!("Error: '{val}' is not an existing directory.");
90 std::process::exit(1);
91 }
92 cfg.project_root = Some(val.clone());
93 }
94 "proxy.anthropic_upstream" => {
95 cfg.proxy.anthropic_upstream = normalize_optional_upstream(val);
96 }
97 "proxy.openai_upstream" => {
98 cfg.proxy.openai_upstream = normalize_optional_upstream(val);
99 }
100 "proxy.gemini_upstream" => {
101 cfg.proxy.gemini_upstream = normalize_optional_upstream(val);
102 }
103 _ => {
104 eprintln!("Unknown config key: {key}");
105 std::process::exit(1);
106 }
107 }
108 match cfg.save() {
109 Ok(()) => println!("Updated {key} = {val}"),
110 Err(e) => eprintln!("Error saving config: {e}"),
111 }
112 }
113 "schema" => {
114 let schema = config::schema::ConfigSchema::generate();
115 println!(
116 "{}",
117 serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
118 );
119 }
120 "validate" => {
121 cmd_validate();
122 }
123 "apply" | "reload" => {
124 cmd_apply();
125 }
126 _ => {
127 eprintln!("Usage: lean-ctx config [init|set|schema|validate|apply]");
128 std::process::exit(1);
129 }
130 }
131}
132
133fn cmd_apply() {
134 use crate::daemon;
135 use crate::ipc;
136
137 println!("Applying config changes…");
138
139 println!("\n[1/4] Validating config…");
141 let schema = config::schema::ConfigSchema::generate();
142 let known = schema.known_keys();
143 let cfg = config::Config::load();
144
145 if let Some(path) = config::Config::path() {
146 if path.exists() {
147 if let Ok(raw) = std::fs::read_to_string(&path) {
148 if let Ok(table) = raw.parse::<toml::Table>() {
149 let mut user_keys = Vec::new();
150 fn collect_flat(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
151 for (k, v) in table {
152 let full = if prefix.is_empty() {
153 k.clone()
154 } else {
155 format!("{prefix}.{k}")
156 };
157 if let toml::Value::Table(sub) = v {
158 collect_flat(sub, &full, out);
159 } else {
160 out.push(full);
161 }
162 }
163 }
164 collect_flat(&table, "", &mut user_keys);
165 let warnings: Vec<_> = user_keys
166 .iter()
167 .filter(|uk| {
168 !known.contains(uk)
169 && !known.iter().any(|k| uk.starts_with(&format!("{k}.")))
170 })
171 .collect();
172 if warnings.is_empty() {
173 println!(" ✓ All config keys valid.");
174 } else {
175 for w in &warnings {
176 eprintln!(" [WARN] Unknown key: {w}");
177 }
178 eprintln!(
179 " {} unknown key(s) found. Continuing anyway…",
180 warnings.len()
181 );
182 }
183 }
184 }
185 }
186 }
187
188 println!("\n[2/4] Restarting processes…");
190 crate::proxy_autostart::stop();
191
192 if let Err(e) = daemon::stop_daemon() {
193 eprintln!(" Warning: daemon stop: {e}");
194 }
195
196 let orphans = ipc::process::kill_all_by_name("lean-ctx");
197 if orphans > 0 {
198 println!(" Terminated {orphans} orphan process(es).");
199 }
200
201 std::thread::sleep(std::time::Duration::from_millis(500));
202
203 let remaining = ipc::process::find_pids_by_name("lean-ctx");
204 if !remaining.is_empty() {
205 for &pid in &remaining {
206 let _ = ipc::process::force_kill(pid);
207 }
208 std::thread::sleep(std::time::Duration::from_millis(300));
209 }
210
211 daemon::cleanup_daemon_files();
212 crate::proxy_autostart::start();
213
214 match daemon::start_daemon(&[]) {
215 Ok(()) => println!(" ✓ Daemon restarted."),
216 Err(e) => {
217 eprintln!(" ✗ Daemon start failed: {e}");
218 std::process::exit(1);
219 }
220 }
221
222 println!("\n[3/4] Running safety checks…");
224 println!(" RAM guard: max {}% system", cfg.max_ram_percent);
225
226 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
227 let sessions_dir = data_dir.join("sessions");
228 let session_count = std::fs::read_dir(&sessions_dir)
229 .map_or(0, |rd| rd.filter_map(std::result::Result::ok).count());
230 println!(" Sessions dir: {session_count} files");
231 }
232
233 println!("\n[4/4] Config applied successfully.");
235 println!(" Theme: {}", cfg.theme);
236 println!(" Ultra compact: {}", cfg.ultra_compact);
237 println!(" Checkpoint: every {} calls", cfg.checkpoint_interval);
238 if let Some(ref root) = cfg.project_root {
239 println!(" Project root: {root}");
240 }
241}
242
243fn cmd_validate() {
244 let schema = config::schema::ConfigSchema::generate();
245 let known = schema.known_keys();
246
247 let path = match config::Config::path() {
248 Some(p) if p.exists() => p,
249 _ => {
250 println!("[OK] No config.toml found — using defaults.");
251 return;
252 }
253 };
254
255 let raw = match std::fs::read_to_string(&path) {
256 Ok(s) => s,
257 Err(e) => {
258 eprintln!("[ERROR] Cannot read {}: {e}", path.display());
259 std::process::exit(1);
260 }
261 };
262
263 let table: toml::Table = match raw.parse() {
264 Ok(t) => t,
265 Err(e) => {
266 eprintln!("[ERROR] Invalid TOML: {e}");
267 std::process::exit(1);
268 }
269 };
270
271 let mut warnings = 0u32;
272 let mut validated = 0u32;
273
274 fn collect_keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
275 for (k, v) in table {
276 let full = if prefix.is_empty() {
277 k.clone()
278 } else {
279 format!("{prefix}.{k}")
280 };
281 match v {
282 toml::Value::Table(sub) => collect_keys(sub, &full, out),
283 toml::Value::Array(arr) => {
284 out.push(full.clone());
285 for item in arr {
286 if let toml::Value::Table(sub) = item {
287 for sk in sub.keys() {
288 out.push(format!("{full}[].{sk}"));
289 }
290 }
291 }
292 }
293 _ => out.push(full),
294 }
295 }
296 }
297
298 let mut user_keys = Vec::new();
299 collect_keys(&table, "", &mut user_keys);
300
301 for uk in &user_keys {
302 let base = uk.split("[].").next().unwrap_or(uk);
303 let field = uk.rsplit("[].").next().unwrap_or("");
304 let check_key = if uk.contains("[].") {
305 format!("{base}.{field}")
306 } else {
307 uk.clone()
308 };
309
310 if known.contains(&check_key)
311 || known
312 .iter()
313 .any(|k| check_key.starts_with(&format!("{k}.")))
314 {
315 validated += 1;
316 } else {
317 warnings += 1;
318 let suggestion = find_closest(&check_key, &known);
319 if let Some(sug) = suggestion {
320 eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
321 } else {
322 eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
323 }
324 }
325 }
326
327 let total = validated + warnings;
328 if warnings == 0 {
329 println!(
330 "[OK] All {total} keys validated successfully ({}).",
331 path.display()
332 );
333 } else {
334 println!(
335 "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
336 path.display()
337 );
338 std::process::exit(1);
339 }
340}
341
342fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
343 let mut best: Option<(usize, &str)> = None;
344 for candidate in haystack {
345 let d = levenshtein(needle, candidate);
346 if d <= 3 && (best.is_none() || d < best.unwrap().0) {
347 best = Some((d, candidate));
348 }
349 }
350 if best.is_some() {
351 return best.map(|(_, s)| s.to_string());
352 }
353 let leaf = needle.rsplit('.').next().unwrap_or(needle);
354 let mut leaf_best: Option<(usize, &str)> = None;
355 for candidate in haystack {
356 let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
357 let d = levenshtein(leaf, cand_leaf);
358 if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
359 leaf_best = Some((d, candidate));
360 }
361 }
362 leaf_best.map(|(_, s)| s.to_string())
363}
364
365fn levenshtein(a: &str, b: &str) -> usize {
366 let a: Vec<char> = a.chars().collect();
367 let b: Vec<char> = b.chars().collect();
368 let (m, n) = (a.len(), b.len());
369 let mut dp = vec![vec![0usize; n + 1]; m + 1];
370 for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
371 row[0] = i;
372 }
373 for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
374 *val = j;
375 }
376 for i in 1..=m {
377 for j in 1..=n {
378 let cost = usize::from(a[i - 1] != b[j - 1]);
379 dp[i][j] = (dp[i - 1][j] + 1)
380 .min(dp[i][j - 1] + 1)
381 .min(dp[i - 1][j - 1] + cost);
382 }
383 }
384 dp[m][n]
385}
386
387fn normalize_optional_upstream(value: &str) -> Option<String> {
388 use crate::core::config::normalize_url_opt;
389 let trimmed = value.trim();
390 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
391 None
392 } else {
393 normalize_url_opt(trimmed)
394 }
395}
396
397pub fn cmd_benchmark(args: &[String]) {
398 use crate::core::benchmark;
399
400 let action = args.first().map_or("run", std::string::String::as_str);
401
402 match action {
403 "--help" | "-h" => {
404 println!("Usage: lean-ctx benchmark run [path] [--json]");
405 println!(" lean-ctx benchmark report [path]");
406 }
407 "run" => {
408 let path = args.get(1).map_or(".", std::string::String::as_str);
409 let is_json = args.iter().any(|a| a == "--json");
410
411 let result = benchmark::run_project_benchmark(path);
412 if is_json {
413 println!("{}", benchmark::format_json(&result));
414 } else {
415 println!("{}", benchmark::format_terminal(&result));
416 }
417 }
418 "report" => {
419 let path = args.get(1).map_or(".", std::string::String::as_str);
420 let result = benchmark::run_project_benchmark(path);
421 println!("{}", benchmark::format_markdown(&result));
422 }
423 _ => {
424 if std::path::Path::new(action).exists() {
425 let result = benchmark::run_project_benchmark(action);
426 println!("{}", benchmark::format_terminal(&result));
427 } else {
428 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
429 eprintln!(" lean-ctx benchmark report [path]");
430 std::process::exit(1);
431 }
432 }
433 }
434}
435
436pub fn cmd_stats(args: &[String]) {
437 match args.first().map(std::string::String::as_str) {
438 Some("reset-cep") => {
439 crate::core::stats::reset_cep();
440 println!("CEP stats reset. Shell hook data preserved.");
441 }
442 Some("json") => {
443 let store = crate::core::stats::load();
444 println!(
445 "{}",
446 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
447 );
448 }
449 _ => {
450 let store = crate::core::stats::load();
451 let input_saved = store
452 .total_input_tokens
453 .saturating_sub(store.total_output_tokens);
454 let pct = if store.total_input_tokens > 0 {
455 input_saved as f64 / store.total_input_tokens as f64 * 100.0
456 } else {
457 0.0
458 };
459 println!("Commands: {}", store.total_commands);
460 println!("Input: {} tokens", store.total_input_tokens);
461 println!("Output: {} tokens", store.total_output_tokens);
462 println!("Saved: {input_saved} tokens ({pct:.1}%)");
463 println!();
464 println!("CEP sessions: {}", store.cep.sessions);
465 println!(
466 "CEP tokens: {} → {}",
467 store.cep.total_tokens_original, store.cep.total_tokens_compressed
468 );
469 println!();
470 println!("Subcommands: stats reset-cep | stats json");
471 }
472 }
473}
474
475pub fn cmd_cache(args: &[String]) {
476 use crate::core::cli_cache;
477 match args.first().map(std::string::String::as_str) {
478 Some("clear") => {
479 let count = cli_cache::clear();
480 println!("Cleared {count} cached entries.");
481 }
482 Some("reset") => {
483 let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
484 if project_flag {
485 let root =
486 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
487 if let Some(root) = root {
488 let count = cli_cache::clear_project(&root);
489 println!("Reset {count} cache entries for project: {root}");
490 } else {
491 eprintln!("No active project root found. Start a session first.");
492 std::process::exit(1);
493 }
494 } else {
495 let count = cli_cache::clear();
496 println!("Reset all {count} cache entries.");
497 }
498 }
499 Some("stats") => {
500 let (hits, reads, entries) = cli_cache::stats();
501 let rate = if reads > 0 {
502 (hits as f64 / reads as f64 * 100.0).round() as u32
503 } else {
504 0
505 };
506 println!("CLI Cache Stats:");
507 println!(" Entries: {entries}");
508 println!(" Reads: {reads}");
509 println!(" Hits: {hits}");
510 println!(" Hit Rate: {rate}%");
511 }
512 Some("invalidate") => {
513 if args.len() < 2 {
514 eprintln!("Usage: lean-ctx cache invalidate <path>");
515 std::process::exit(1);
516 }
517 cli_cache::invalidate(&args[1]);
518 println!("Invalidated cache for {}", args[1]);
519 }
520 Some("prune") => {
521 let bm25 = prune_bm25_caches();
522 let graph = prune_graph_caches();
523 let removed = bm25.removed + graph.removed;
524 let freed = bm25.bytes_freed + graph.bytes_freed;
525 println!(
526 "Pruned {} entries, freed {:.1} MB (BM25: {}, graphs: {})",
527 removed,
528 freed as f64 / 1_048_576.0,
529 bm25.removed,
530 graph.removed,
531 );
532 }
533 _ => {
534 let (hits, reads, entries) = cli_cache::stats();
535 let rate = if reads > 0 {
536 (hits as f64 / reads as f64 * 100.0).round() as u32
537 } else {
538 0
539 };
540 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
541 println!();
542 println!("Subcommands:");
543 println!(" cache stats Show detailed stats");
544 println!(" cache clear Clear all cached entries");
545 println!(" cache reset Reset all cache (or --project for current project only)");
546 println!(" cache invalidate Remove specific file from cache");
547 println!(
548 " cache prune Remove oversized, quarantined, and orphaned indexes (BM25 + graphs)"
549 );
550 }
551 }
552}
553
554pub struct PruneResult {
555 pub scanned: u32,
556 pub removed: u32,
557 pub bytes_freed: u64,
558}
559
560pub fn prune_bm25_caches() -> PruneResult {
561 let mut result = PruneResult {
562 scanned: 0,
563 removed: 0,
564 bytes_freed: 0,
565 };
566
567 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
568 return result;
569 };
570 let vectors_dir = data_dir.join("vectors");
571 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
572 return result;
573 };
574
575 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
576
577 for entry in entries.flatten() {
578 let dir = entry.path();
579 if !dir.is_dir() {
580 continue;
581 }
582 result.scanned += 1;
583
584 for q_name in &[
585 "bm25_index.json.quarantined",
586 "bm25_index.bin.quarantined",
587 "bm25_index.bin.zst.quarantined",
588 ] {
589 let quarantined = dir.join(q_name);
590 if quarantined.exists() {
591 if let Ok(meta) = std::fs::metadata(&quarantined) {
592 result.bytes_freed += meta.len();
593 }
594 let _ = std::fs::remove_file(&quarantined);
595 result.removed += 1;
596 println!(" Removed quarantined: {}", quarantined.display());
597 }
598 }
599
600 let index_path = if dir.join("bm25_index.bin.zst").exists() {
601 dir.join("bm25_index.bin.zst")
602 } else if dir.join("bm25_index.bin").exists() {
603 dir.join("bm25_index.bin")
604 } else {
605 dir.join("bm25_index.json")
606 };
607 if let Ok(meta) = std::fs::metadata(&index_path) {
608 if meta.len() > max_bytes {
609 result.bytes_freed += meta.len();
610 let _ = std::fs::remove_file(&index_path);
611 result.removed += 1;
612 println!(
613 " Removed oversized ({:.1} MB): {}",
614 meta.len() as f64 / 1_048_576.0,
615 index_path.display()
616 );
617 }
618 }
619
620 let marker = dir.join("project_root.txt");
621 if let Ok(root_str) = std::fs::read_to_string(&marker) {
622 let root_path = std::path::Path::new(root_str.trim());
623 if !root_path.exists() {
624 let freed = dir_size(&dir);
625 result.bytes_freed += freed;
626 let _ = std::fs::remove_dir_all(&dir);
627 result.removed += 1;
628 println!(
629 " Removed orphaned ({:.1} MB, project gone: {}): {}",
630 freed as f64 / 1_048_576.0,
631 root_str.trim(),
632 dir.display()
633 );
634 }
635 }
636 }
637
638 result
639}
640
641pub fn prune_graph_caches() -> PruneResult {
642 let mut result = PruneResult {
643 scanned: 0,
644 removed: 0,
645 bytes_freed: 0,
646 };
647
648 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
649 return result;
650 };
651 let graphs_dir = data_dir.join("graphs");
652 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
653 return result;
654 };
655
656 for entry in entries.flatten() {
657 let dir = entry.path();
658 if !dir.is_dir() {
659 continue;
660 }
661 result.scanned += 1;
662
663 let index_path = dir.join("index.json.zst");
664 let index_json = dir.join("index.json");
665
666 let has_index = index_path.exists() || index_json.exists();
667 if !has_index {
668 continue;
669 }
670
671 let idx_file = if index_path.exists() {
672 &index_path
673 } else {
674 &index_json
675 };
676
677 let root_from_index = try_read_project_root_from_graph(idx_file);
678 if let Some(root) = root_from_index {
679 if !root.is_empty() && !std::path::Path::new(&root).exists() {
680 let freed = dir_size(&dir);
681 result.bytes_freed += freed;
682 let _ = std::fs::remove_dir_all(&dir);
683 result.removed += 1;
684 println!(
685 " Removed orphaned graph ({:.1} MB, project gone: {}): {}",
686 freed as f64 / 1_048_576.0,
687 root,
688 dir.display()
689 );
690 continue;
691 }
692 }
693
694 if let Ok(meta) = std::fs::metadata(idx_file) {
695 if meta.len() > 100 * 1024 * 1024 {
696 result.bytes_freed += meta.len();
697 let _ = std::fs::remove_file(idx_file);
698 result.removed += 1;
699 println!(
700 " Removed oversized graph ({:.1} MB): {}",
701 meta.len() as f64 / 1_048_576.0,
702 idx_file.display()
703 );
704 }
705 }
706 }
707
708 result
709}
710
711fn try_read_project_root_from_graph(path: &std::path::Path) -> Option<String> {
712 let data = if path.extension().and_then(|e| e.to_str()) == Some("zst") {
713 let compressed = std::fs::read(path).ok()?;
714 zstd::decode_all(compressed.as_slice()).ok()?
715 } else {
716 std::fs::read(path).ok()?
717 };
718 let content = String::from_utf8(data).ok()?;
719 let val: serde_json::Value = serde_json::from_str(&content).ok()?;
720 val.get("project_root")?.as_str().map(String::from)
721}
722
723fn dir_size(path: &std::path::Path) -> u64 {
724 let mut total = 0u64;
725 if let Ok(entries) = std::fs::read_dir(path) {
726 for entry in entries.flatten() {
727 let p = entry.path();
728 if p.is_file() {
729 total += std::fs::metadata(&p).map_or(0, |m| m.len());
730 } else if p.is_dir() {
731 total += dir_size(&p);
732 }
733 }
734 }
735 total
736}