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 "proxy.anthropic_upstream" => {
84 cfg.proxy.anthropic_upstream = normalize_optional_upstream(val);
85 }
86 "proxy.openai_upstream" => {
87 cfg.proxy.openai_upstream = normalize_optional_upstream(val);
88 }
89 "proxy.gemini_upstream" => {
90 cfg.proxy.gemini_upstream = normalize_optional_upstream(val);
91 }
92 _ => {
93 eprintln!("Unknown config key: {key}");
94 std::process::exit(1);
95 }
96 }
97 match cfg.save() {
98 Ok(()) => println!("Updated {key} = {val}"),
99 Err(e) => eprintln!("Error saving config: {e}"),
100 }
101 }
102 "schema" => {
103 let schema = config::schema::ConfigSchema::generate();
104 println!(
105 "{}",
106 serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_string())
107 );
108 }
109 "validate" => {
110 cmd_validate();
111 }
112 _ => {
113 eprintln!("Usage: lean-ctx config [init|set|schema|validate]");
114 std::process::exit(1);
115 }
116 }
117}
118
119fn cmd_validate() {
120 let schema = config::schema::ConfigSchema::generate();
121 let known = schema.known_keys();
122
123 let path = match config::Config::path() {
124 Some(p) if p.exists() => p,
125 _ => {
126 println!("[OK] No config.toml found — using defaults.");
127 return;
128 }
129 };
130
131 let raw = match std::fs::read_to_string(&path) {
132 Ok(s) => s,
133 Err(e) => {
134 eprintln!("[ERROR] Cannot read {}: {e}", path.display());
135 std::process::exit(1);
136 }
137 };
138
139 let table: toml::Table = match raw.parse() {
140 Ok(t) => t,
141 Err(e) => {
142 eprintln!("[ERROR] Invalid TOML: {e}");
143 std::process::exit(1);
144 }
145 };
146
147 let mut warnings = 0u32;
148 let mut validated = 0u32;
149
150 fn collect_keys(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 match v {
158 toml::Value::Table(sub) => collect_keys(sub, &full, out),
159 toml::Value::Array(arr) => {
160 out.push(full.clone());
161 for item in arr {
162 if let toml::Value::Table(sub) = item {
163 for sk in sub.keys() {
164 out.push(format!("{full}[].{sk}"));
165 }
166 }
167 }
168 }
169 _ => out.push(full),
170 }
171 }
172 }
173
174 let mut user_keys = Vec::new();
175 collect_keys(&table, "", &mut user_keys);
176
177 for uk in &user_keys {
178 let base = uk.split("[].").next().unwrap_or(uk);
179 let field = uk.rsplit("[].").next().unwrap_or("");
180 let check_key = if uk.contains("[].") {
181 format!("{base}.{field}")
182 } else {
183 uk.clone()
184 };
185
186 if known.contains(&check_key)
187 || known
188 .iter()
189 .any(|k| check_key.starts_with(&format!("{k}.")))
190 {
191 validated += 1;
192 } else {
193 warnings += 1;
194 let suggestion = find_closest(&check_key, &known);
195 if let Some(sug) = suggestion {
196 eprintln!("[WARN] Unknown key '{uk}' -- did you mean '{sug}'?");
197 } else {
198 eprintln!("[WARN] Unknown key '{uk}' -- this field does not exist");
199 }
200 }
201 }
202
203 let total = validated + warnings;
204 if warnings == 0 {
205 println!(
206 "[OK] All {total} keys validated successfully ({}).",
207 path.display()
208 );
209 } else {
210 println!(
211 "[RESULT] {validated} of {total} keys validated, {warnings} unknown ({}).",
212 path.display()
213 );
214 std::process::exit(1);
215 }
216}
217
218fn find_closest(needle: &str, haystack: &[String]) -> Option<String> {
219 let mut best: Option<(usize, &str)> = None;
220 for candidate in haystack {
221 let d = levenshtein(needle, candidate);
222 if d <= 3 && (best.is_none() || d < best.unwrap().0) {
223 best = Some((d, candidate));
224 }
225 }
226 if best.is_some() {
227 return best.map(|(_, s)| s.to_string());
228 }
229 let leaf = needle.rsplit('.').next().unwrap_or(needle);
230 let mut leaf_best: Option<(usize, &str)> = None;
231 for candidate in haystack {
232 let cand_leaf = candidate.rsplit('.').next().unwrap_or(candidate);
233 let d = levenshtein(leaf, cand_leaf);
234 if d <= 2 && (leaf_best.is_none() || d < leaf_best.unwrap().0) {
235 leaf_best = Some((d, candidate));
236 }
237 }
238 leaf_best.map(|(_, s)| s.to_string())
239}
240
241fn levenshtein(a: &str, b: &str) -> usize {
242 let a: Vec<char> = a.chars().collect();
243 let b: Vec<char> = b.chars().collect();
244 let (m, n) = (a.len(), b.len());
245 let mut dp = vec![vec![0usize; n + 1]; m + 1];
246 for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
247 row[0] = i;
248 }
249 for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
250 *val = j;
251 }
252 for i in 1..=m {
253 for j in 1..=n {
254 let cost = usize::from(a[i - 1] != b[j - 1]);
255 dp[i][j] = (dp[i - 1][j] + 1)
256 .min(dp[i][j - 1] + 1)
257 .min(dp[i - 1][j - 1] + cost);
258 }
259 }
260 dp[m][n]
261}
262
263fn normalize_optional_upstream(value: &str) -> Option<String> {
264 use crate::core::config::normalize_url_opt;
265 let trimmed = value.trim();
266 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
267 None
268 } else {
269 normalize_url_opt(trimmed)
270 }
271}
272
273pub fn cmd_benchmark(args: &[String]) {
274 use crate::core::benchmark;
275
276 let action = args.first().map_or("run", std::string::String::as_str);
277
278 match action {
279 "--help" | "-h" => {
280 println!("Usage: lean-ctx benchmark run [path] [--json]");
281 println!(" lean-ctx benchmark report [path]");
282 }
283 "run" => {
284 let path = args.get(1).map_or(".", std::string::String::as_str);
285 let is_json = args.iter().any(|a| a == "--json");
286
287 let result = benchmark::run_project_benchmark(path);
288 if is_json {
289 println!("{}", benchmark::format_json(&result));
290 } else {
291 println!("{}", benchmark::format_terminal(&result));
292 }
293 }
294 "report" => {
295 let path = args.get(1).map_or(".", std::string::String::as_str);
296 let result = benchmark::run_project_benchmark(path);
297 println!("{}", benchmark::format_markdown(&result));
298 }
299 _ => {
300 if std::path::Path::new(action).exists() {
301 let result = benchmark::run_project_benchmark(action);
302 println!("{}", benchmark::format_terminal(&result));
303 } else {
304 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
305 eprintln!(" lean-ctx benchmark report [path]");
306 std::process::exit(1);
307 }
308 }
309 }
310}
311
312pub fn cmd_stats(args: &[String]) {
313 match args.first().map(std::string::String::as_str) {
314 Some("reset-cep") => {
315 crate::core::stats::reset_cep();
316 println!("CEP stats reset. Shell hook data preserved.");
317 }
318 Some("json") => {
319 let store = crate::core::stats::load();
320 println!(
321 "{}",
322 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
323 );
324 }
325 _ => {
326 let store = crate::core::stats::load();
327 let input_saved = store
328 .total_input_tokens
329 .saturating_sub(store.total_output_tokens);
330 let pct = if store.total_input_tokens > 0 {
331 input_saved as f64 / store.total_input_tokens as f64 * 100.0
332 } else {
333 0.0
334 };
335 println!("Commands: {}", store.total_commands);
336 println!("Input: {} tokens", store.total_input_tokens);
337 println!("Output: {} tokens", store.total_output_tokens);
338 println!("Saved: {input_saved} tokens ({pct:.1}%)");
339 println!();
340 println!("CEP sessions: {}", store.cep.sessions);
341 println!(
342 "CEP tokens: {} → {}",
343 store.cep.total_tokens_original, store.cep.total_tokens_compressed
344 );
345 println!();
346 println!("Subcommands: stats reset-cep | stats json");
347 }
348 }
349}
350
351pub fn cmd_cache(args: &[String]) {
352 use crate::core::cli_cache;
353 match args.first().map(std::string::String::as_str) {
354 Some("clear") => {
355 let count = cli_cache::clear();
356 println!("Cleared {count} cached entries.");
357 }
358 Some("reset") => {
359 let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
360 if project_flag {
361 let root =
362 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
363 if let Some(root) = root {
364 let count = cli_cache::clear_project(&root);
365 println!("Reset {count} cache entries for project: {root}");
366 } else {
367 eprintln!("No active project root found. Start a session first.");
368 std::process::exit(1);
369 }
370 } else {
371 let count = cli_cache::clear();
372 println!("Reset all {count} cache entries.");
373 }
374 }
375 Some("stats") => {
376 let (hits, reads, entries) = cli_cache::stats();
377 let rate = if reads > 0 {
378 (hits as f64 / reads as f64 * 100.0).round() as u32
379 } else {
380 0
381 };
382 println!("CLI Cache Stats:");
383 println!(" Entries: {entries}");
384 println!(" Reads: {reads}");
385 println!(" Hits: {hits}");
386 println!(" Hit Rate: {rate}%");
387 }
388 Some("invalidate") => {
389 if args.len() < 2 {
390 eprintln!("Usage: lean-ctx cache invalidate <path>");
391 std::process::exit(1);
392 }
393 cli_cache::invalidate(&args[1]);
394 println!("Invalidated cache for {}", args[1]);
395 }
396 Some("prune") => {
397 let result = prune_bm25_caches();
398 println!(
399 "Pruned {} entries, freed {:.1} MB",
400 result.removed,
401 result.bytes_freed as f64 / 1_048_576.0
402 );
403 }
404 _ => {
405 let (hits, reads, entries) = cli_cache::stats();
406 let rate = if reads > 0 {
407 (hits as f64 / reads as f64 * 100.0).round() as u32
408 } else {
409 0
410 };
411 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
412 println!();
413 println!("Subcommands:");
414 println!(" cache stats Show detailed stats");
415 println!(" cache clear Clear all cached entries");
416 println!(" cache reset Reset all cache (or --project for current project only)");
417 println!(" cache invalidate Remove specific file from cache");
418 println!(
419 " cache prune Remove oversized, quarantined, and orphaned BM25 indexes"
420 );
421 }
422 }
423}
424
425pub struct PruneResult {
426 pub scanned: u32,
427 pub removed: u32,
428 pub bytes_freed: u64,
429}
430
431pub fn prune_bm25_caches() -> PruneResult {
432 let mut result = PruneResult {
433 scanned: 0,
434 removed: 0,
435 bytes_freed: 0,
436 };
437
438 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
439 return result;
440 };
441 let vectors_dir = data_dir.join("vectors");
442 let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
443 return result;
444 };
445
446 let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
447
448 for entry in entries.flatten() {
449 let dir = entry.path();
450 if !dir.is_dir() {
451 continue;
452 }
453 result.scanned += 1;
454
455 let quarantined = dir.join("bm25_index.json.quarantined");
456 if quarantined.exists() {
457 if let Ok(meta) = std::fs::metadata(&quarantined) {
458 result.bytes_freed += meta.len();
459 }
460 let _ = std::fs::remove_file(&quarantined);
461 result.removed += 1;
462 println!(" Removed quarantined: {}", quarantined.display());
463 }
464
465 let index_path = dir.join("bm25_index.json");
466 if let Ok(meta) = std::fs::metadata(&index_path) {
467 if meta.len() > max_bytes {
468 result.bytes_freed += meta.len();
469 let _ = std::fs::remove_file(&index_path);
470 result.removed += 1;
471 println!(
472 " Removed oversized ({:.1} MB): {}",
473 meta.len() as f64 / 1_048_576.0,
474 index_path.display()
475 );
476 }
477 }
478
479 let marker = dir.join("project_root.txt");
480 if let Ok(root_str) = std::fs::read_to_string(&marker) {
481 let root_path = std::path::Path::new(root_str.trim());
482 if !root_path.exists() {
483 let freed = dir_size(&dir);
484 result.bytes_freed += freed;
485 let _ = std::fs::remove_dir_all(&dir);
486 result.removed += 1;
487 println!(
488 " Removed orphaned ({:.1} MB, project gone: {}): {}",
489 freed as f64 / 1_048_576.0,
490 root_str.trim(),
491 dir.display()
492 );
493 }
494 }
495 }
496
497 result
498}
499
500fn dir_size(path: &std::path::Path) -> u64 {
501 let mut total = 0u64;
502 if let Ok(entries) = std::fs::read_dir(path) {
503 for entry in entries.flatten() {
504 let p = entry.path();
505 if p.is_file() {
506 total += std::fs::metadata(&p).map_or(0, |m| m.len());
507 } else if p.is_dir() {
508 total += dir_size(&p);
509 }
510 }
511 }
512 total
513}