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