lean_ctx/cli/
config_cmd.rs1use 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}