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 result = prune_bm25_caches();
522 println!(
523 "Pruned {} entries, freed {:.1} MB",
524 result.removed,
525 result.bytes_freed as f64 / 1_048_576.0
526 );
527 }
528 _ => {
529 let (hits, reads, entries) = cli_cache::stats();
530 let rate = if reads > 0 {
531 (hits as f64 / reads as f64 * 100.0).round() as u32
532 } else {
533 0
534 };
535 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
536 println!();
537 println!("Subcommands:");
538 println!(" cache stats Show detailed stats");
539 println!(" cache clear Clear all cached entries");
540 println!(" cache reset Reset all cache (or --project for current project only)");
541 println!(" cache invalidate Remove specific file from cache");
542 println!(
543 " cache prune Remove oversized, quarantined, and orphaned BM25 indexes"
544 );
545 }
546 }
547}
548
549pub struct PruneResult {
550 pub scanned: u32,
551 pub removed: u32,
552 pub bytes_freed: u64,
553}
554
555pub fn prune_bm25_caches() -> PruneResult {
556 let mut result = PruneResult {
557 scanned: 0,
558 removed: 0,
559 bytes_freed: 0,
560 };
561
562 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
563 return result;
564 };
565 let vectors_dir = data_dir.join("vectors");
566 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
567 return result;
568 };
569
570 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
571
572 for entry in entries.flatten() {
573 let dir = entry.path();
574 if !dir.is_dir() {
575 continue;
576 }
577 result.scanned += 1;
578
579 for q_name in &[
580 "bm25_index.json.quarantined",
581 "bm25_index.bin.quarantined",
582 "bm25_index.bin.zst.quarantined",
583 ] {
584 let quarantined = dir.join(q_name);
585 if quarantined.exists() {
586 if let Ok(meta) = std::fs::metadata(&quarantined) {
587 result.bytes_freed += meta.len();
588 }
589 let _ = std::fs::remove_file(&quarantined);
590 result.removed += 1;
591 println!(" Removed quarantined: {}", quarantined.display());
592 }
593 }
594
595 let index_path = if dir.join("bm25_index.bin.zst").exists() {
596 dir.join("bm25_index.bin.zst")
597 } else if dir.join("bm25_index.bin").exists() {
598 dir.join("bm25_index.bin")
599 } else {
600 dir.join("bm25_index.json")
601 };
602 if let Ok(meta) = std::fs::metadata(&index_path) {
603 if meta.len() > max_bytes {
604 result.bytes_freed += meta.len();
605 let _ = std::fs::remove_file(&index_path);
606 result.removed += 1;
607 println!(
608 " Removed oversized ({:.1} MB): {}",
609 meta.len() as f64 / 1_048_576.0,
610 index_path.display()
611 );
612 }
613 }
614
615 let marker = dir.join("project_root.txt");
616 if let Ok(root_str) = std::fs::read_to_string(&marker) {
617 let root_path = std::path::Path::new(root_str.trim());
618 if !root_path.exists() {
619 let freed = dir_size(&dir);
620 result.bytes_freed += freed;
621 let _ = std::fs::remove_dir_all(&dir);
622 result.removed += 1;
623 println!(
624 " Removed orphaned ({:.1} MB, project gone: {}): {}",
625 freed as f64 / 1_048_576.0,
626 root_str.trim(),
627 dir.display()
628 );
629 }
630 }
631 }
632
633 result
634}
635
636fn dir_size(path: &std::path::Path) -> u64 {
637 let mut total = 0u64;
638 if let Ok(entries) = std::fs::read_dir(path) {
639 for entry in entries.flatten() {
640 let p = entry.path();
641 if p.is_file() {
642 total += std::fs::metadata(&p).map_or(0, |m| m.len());
643 } else if p.is_dir() {
644 total += dir_size(&p);
645 }
646 }
647 }
648 total
649}