Skip to main content

lean_ctx/cli/
config_cmd.rs

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        _ => {
103            eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
104            std::process::exit(1);
105        }
106    }
107}
108
109fn normalize_optional_upstream(value: &str) -> Option<String> {
110    use crate::core::config::normalize_url_opt;
111    let trimmed = value.trim();
112    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") {
113        None
114    } else {
115        normalize_url_opt(trimmed)
116    }
117}
118
119pub fn cmd_benchmark(args: &[String]) {
120    use crate::core::benchmark;
121
122    let action = args.first().map_or("run", std::string::String::as_str);
123
124    match action {
125        "--help" | "-h" => {
126            println!("Usage: lean-ctx benchmark run [path] [--json]");
127            println!("       lean-ctx benchmark report [path]");
128        }
129        "run" => {
130            let path = args.get(1).map_or(".", std::string::String::as_str);
131            let is_json = args.iter().any(|a| a == "--json");
132
133            let result = benchmark::run_project_benchmark(path);
134            if is_json {
135                println!("{}", benchmark::format_json(&result));
136            } else {
137                println!("{}", benchmark::format_terminal(&result));
138            }
139        }
140        "report" => {
141            let path = args.get(1).map_or(".", std::string::String::as_str);
142            let result = benchmark::run_project_benchmark(path);
143            println!("{}", benchmark::format_markdown(&result));
144        }
145        _ => {
146            if std::path::Path::new(action).exists() {
147                let result = benchmark::run_project_benchmark(action);
148                println!("{}", benchmark::format_terminal(&result));
149            } else {
150                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
151                eprintln!("       lean-ctx benchmark report [path]");
152                std::process::exit(1);
153            }
154        }
155    }
156}
157
158pub fn cmd_stats(args: &[String]) {
159    match args.first().map(std::string::String::as_str) {
160        Some("reset-cep") => {
161            crate::core::stats::reset_cep();
162            println!("CEP stats reset. Shell hook data preserved.");
163        }
164        Some("json") => {
165            let store = crate::core::stats::load();
166            println!(
167                "{}",
168                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
169            );
170        }
171        _ => {
172            let store = crate::core::stats::load();
173            let input_saved = store
174                .total_input_tokens
175                .saturating_sub(store.total_output_tokens);
176            let pct = if store.total_input_tokens > 0 {
177                input_saved as f64 / store.total_input_tokens as f64 * 100.0
178            } else {
179                0.0
180            };
181            println!("Commands:    {}", store.total_commands);
182            println!("Input:       {} tokens", store.total_input_tokens);
183            println!("Output:      {} tokens", store.total_output_tokens);
184            println!("Saved:       {input_saved} tokens ({pct:.1}%)");
185            println!();
186            println!("CEP sessions:  {}", store.cep.sessions);
187            println!(
188                "CEP tokens:    {} → {}",
189                store.cep.total_tokens_original, store.cep.total_tokens_compressed
190            );
191            println!();
192            println!("Subcommands: stats reset-cep | stats json");
193        }
194    }
195}
196
197pub fn cmd_cache(args: &[String]) {
198    use crate::core::cli_cache;
199    match args.first().map(std::string::String::as_str) {
200        Some("clear") => {
201            let count = cli_cache::clear();
202            println!("Cleared {count} cached entries.");
203        }
204        Some("reset") => {
205            let project_flag = args.get(1).map(std::string::String::as_str) == Some("--project");
206            if project_flag {
207                let root =
208                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
209                if let Some(root) = root {
210                    let count = cli_cache::clear_project(&root);
211                    println!("Reset {count} cache entries for project: {root}");
212                } else {
213                    eprintln!("No active project root found. Start a session first.");
214                    std::process::exit(1);
215                }
216            } else {
217                let count = cli_cache::clear();
218                println!("Reset all {count} cache entries.");
219            }
220        }
221        Some("stats") => {
222            let (hits, reads, entries) = cli_cache::stats();
223            let rate = if reads > 0 {
224                (hits as f64 / reads as f64 * 100.0).round() as u32
225            } else {
226                0
227            };
228            println!("CLI Cache Stats:");
229            println!("  Entries:   {entries}");
230            println!("  Reads:     {reads}");
231            println!("  Hits:      {hits}");
232            println!("  Hit Rate:  {rate}%");
233        }
234        Some("invalidate") => {
235            if args.len() < 2 {
236                eprintln!("Usage: lean-ctx cache invalidate <path>");
237                std::process::exit(1);
238            }
239            cli_cache::invalidate(&args[1]);
240            println!("Invalidated cache for {}", args[1]);
241        }
242        Some("prune") => {
243            let result = prune_bm25_caches();
244            println!(
245                "Pruned {} entries, freed {:.1} MB",
246                result.removed,
247                result.bytes_freed as f64 / 1_048_576.0
248            );
249        }
250        _ => {
251            let (hits, reads, entries) = cli_cache::stats();
252            let rate = if reads > 0 {
253                (hits as f64 / reads as f64 * 100.0).round() as u32
254            } else {
255                0
256            };
257            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
258            println!();
259            println!("Subcommands:");
260            println!("  cache stats       Show detailed stats");
261            println!("  cache clear       Clear all cached entries");
262            println!("  cache reset       Reset all cache (or --project for current project only)");
263            println!("  cache invalidate  Remove specific file from cache");
264            println!(
265                "  cache prune       Remove oversized, quarantined, and orphaned BM25 indexes"
266            );
267        }
268    }
269}
270
271pub struct PruneResult {
272    pub scanned: u32,
273    pub removed: u32,
274    pub bytes_freed: u64,
275}
276
277pub fn prune_bm25_caches() -> PruneResult {
278    let mut result = PruneResult {
279        scanned: 0,
280        removed: 0,
281        bytes_freed: 0,
282    };
283
284    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
285        return result;
286    };
287    let vectors_dir = data_dir.join("vectors");
288    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
289        return result;
290    };
291
292    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
293
294    for entry in entries.flatten() {
295        let dir = entry.path();
296        if !dir.is_dir() {
297            continue;
298        }
299        result.scanned += 1;
300
301        let quarantined = dir.join("bm25_index.json.quarantined");
302        if quarantined.exists() {
303            if let Ok(meta) = std::fs::metadata(&quarantined) {
304                result.bytes_freed += meta.len();
305            }
306            let _ = std::fs::remove_file(&quarantined);
307            result.removed += 1;
308            println!("  Removed quarantined: {}", quarantined.display());
309        }
310
311        let index_path = dir.join("bm25_index.json");
312        if let Ok(meta) = std::fs::metadata(&index_path) {
313            if meta.len() > max_bytes {
314                result.bytes_freed += meta.len();
315                let _ = std::fs::remove_file(&index_path);
316                result.removed += 1;
317                println!(
318                    "  Removed oversized ({:.1} MB): {}",
319                    meta.len() as f64 / 1_048_576.0,
320                    index_path.display()
321                );
322            }
323        }
324
325        let marker = dir.join("project_root.txt");
326        if let Ok(root_str) = std::fs::read_to_string(&marker) {
327            let root_path = std::path::Path::new(root_str.trim());
328            if !root_path.exists() {
329                let freed = dir_size(&dir);
330                result.bytes_freed += freed;
331                let _ = std::fs::remove_dir_all(&dir);
332                result.removed += 1;
333                println!(
334                    "  Removed orphaned ({:.1} MB, project gone: {}): {}",
335                    freed as f64 / 1_048_576.0,
336                    root_str.trim(),
337                    dir.display()
338                );
339            }
340        }
341    }
342
343    result
344}
345
346fn dir_size(path: &std::path::Path) -> u64 {
347    let mut total = 0u64;
348    if let Ok(entries) = std::fs::read_dir(path) {
349        for entry in entries.flatten() {
350            let p = entry.path();
351            if p.is_file() {
352                total += std::fs::metadata(&p).map_or(0, |m| m.len());
353            } else if p.is_dir() {
354                total += dir_size(&p);
355            }
356        }
357    }
358    total
359}