lean_ctx/core/patterns/
cargo.rs1use regex::Regex;
2use std::sync::OnceLock;
3
4static COMPILING_RE: OnceLock<Regex> = OnceLock::new();
5static ERROR_RE: OnceLock<Regex> = OnceLock::new();
6static WARNING_RE: OnceLock<Regex> = OnceLock::new();
7static TEST_RESULT_RE: OnceLock<Regex> = OnceLock::new();
8static FINISHED_RE: OnceLock<Regex> = OnceLock::new();
9
10fn compiling_re() -> &'static Regex {
11 COMPILING_RE.get_or_init(|| Regex::new(r"Compiling (\S+) v(\S+)").unwrap())
12}
13fn error_re() -> &'static Regex {
14 ERROR_RE.get_or_init(|| Regex::new(r"error\[E(\d+)\]: (.+)").unwrap())
15}
16fn warning_re() -> &'static Regex {
17 WARNING_RE.get_or_init(|| Regex::new(r"warning: (.+)").unwrap())
18}
19fn test_result_re() -> &'static Regex {
20 TEST_RESULT_RE.get_or_init(|| {
21 Regex::new(r"test result: (\w+)\. (\d+) passed; (\d+) failed; (\d+) ignored").unwrap()
22 })
23}
24fn finished_re() -> &'static Regex {
25 FINISHED_RE.get_or_init(|| Regex::new(r"Finished .+ in (\d+\.?\d*s)").unwrap())
26}
27
28pub fn compress(command: &str, output: &str) -> Option<String> {
29 if command.contains("build") || command.contains("check") {
30 return Some(compress_build(output));
31 }
32 if command.contains("test") {
33 return Some(compress_test(output));
34 }
35 if command.contains("clippy") {
36 return Some(compress_clippy(output));
37 }
38 if command.contains("doc") {
39 return Some(compress_doc(output));
40 }
41 if command.contains("tree") {
42 return Some(compress_tree(output));
43 }
44 if command.contains("fmt") {
45 return Some(compress_fmt(output));
46 }
47 if command.contains("update") {
48 return Some(compress_update(output));
49 }
50 if command.contains("metadata") {
51 return Some(compress_metadata(output));
52 }
53 None
54}
55
56fn compress_build(output: &str) -> String {
57 let mut crate_count = 0u32;
58 let mut errors = Vec::new();
59 let mut warnings = 0u32;
60 let mut time = String::new();
61
62 for line in output.lines() {
63 if compiling_re().is_match(line) {
64 crate_count += 1;
65 }
66 if let Some(caps) = error_re().captures(line) {
67 errors.push(format!("E{}: {}", &caps[1], &caps[2]));
68 }
69 if warning_re().is_match(line) && !line.contains("generated") {
70 warnings += 1;
71 }
72 if let Some(caps) = finished_re().captures(line) {
73 time = caps[1].to_string();
74 }
75 }
76
77 let mut parts = Vec::new();
78 if crate_count > 0 {
79 parts.push(format!("compiled {crate_count} crates"));
80 }
81 if !errors.is_empty() {
82 parts.push(format!("{} errors:", errors.len()));
83 for e in &errors {
84 parts.push(format!(" {e}"));
85 }
86 }
87 if warnings > 0 {
88 parts.push(format!("{warnings} warnings"));
89 }
90 if !time.is_empty() {
91 parts.push(format!("({time})"));
92 }
93
94 if parts.is_empty() {
95 return "ok".to_string();
96 }
97 parts.join("\n")
98}
99
100fn compress_test(output: &str) -> String {
101 let mut results = Vec::new();
102 let mut failed_tests = Vec::new();
103 let mut time = String::new();
104
105 for line in output.lines() {
106 if let Some(caps) = test_result_re().captures(line) {
107 results.push(format!(
108 "{}: {} pass, {} fail, {} skip",
109 &caps[1], &caps[2], &caps[3], &caps[4]
110 ));
111 }
112 if line.contains("FAILED") && line.contains("---") {
113 let name = line.split_whitespace().nth(1).unwrap_or("?");
114 failed_tests.push(name.to_string());
115 }
116 if let Some(caps) = finished_re().captures(line) {
117 time = caps[1].to_string();
118 }
119 }
120
121 let mut parts = Vec::new();
122 if !results.is_empty() {
123 parts.extend(results);
124 }
125 if !failed_tests.is_empty() {
126 parts.push(format!("failed: {}", failed_tests.join(", ")));
127 }
128 if !time.is_empty() {
129 parts.push(format!("({time})"));
130 }
131
132 if parts.is_empty() {
133 return "ok".to_string();
134 }
135 parts.join("\n")
136}
137
138fn compress_clippy(output: &str) -> String {
139 let mut warnings = Vec::new();
140 let mut errors = Vec::new();
141
142 for line in output.lines() {
143 if let Some(caps) = error_re().captures(line) {
144 errors.push(caps[2].to_string());
145 } else if let Some(caps) = warning_re().captures(line) {
146 let msg = &caps[1];
147 if !msg.contains("generated") && !msg.starts_with('`') {
148 warnings.push(msg.to_string());
149 }
150 }
151 }
152
153 let mut parts = Vec::new();
154 if !errors.is_empty() {
155 parts.push(format!("{} errors: {}", errors.len(), errors.join("; ")));
156 }
157 if !warnings.is_empty() {
158 parts.push(format!("{} warnings", warnings.len()));
159 }
160
161 if parts.is_empty() {
162 return "clean".to_string();
163 }
164 parts.join("\n")
165}
166
167fn compress_doc(output: &str) -> String {
168 let mut crate_count = 0u32;
169 let mut warnings = 0u32;
170 let mut time = String::new();
171
172 for line in output.lines() {
173 if line.contains("Documenting ") || compiling_re().is_match(line) {
174 crate_count += 1;
175 }
176 if warning_re().is_match(line) && !line.contains("generated") {
177 warnings += 1;
178 }
179 if let Some(caps) = finished_re().captures(line) {
180 time = caps[1].to_string();
181 }
182 }
183
184 let mut parts = Vec::new();
185 if crate_count > 0 {
186 parts.push(format!("documented {crate_count} crates"));
187 }
188 if warnings > 0 {
189 parts.push(format!("{warnings} warnings"));
190 }
191 if !time.is_empty() {
192 parts.push(format!("({time})"));
193 }
194 if parts.is_empty() {
195 "ok".to_string()
196 } else {
197 parts.join("\n")
198 }
199}
200
201fn compress_tree(output: &str) -> String {
202 let lines: Vec<&str> = output.lines().collect();
203 if lines.len() <= 20 {
204 return output.to_string();
205 }
206
207 let direct: Vec<&str> = lines
208 .iter()
209 .filter(|l| !l.starts_with(' ') || l.starts_with("├── ") || l.starts_with("└── "))
210 .copied()
211 .collect();
212
213 if direct.is_empty() {
214 let shown = &lines[..20.min(lines.len())];
215 return format!(
216 "{}\n... ({} more lines)",
217 shown.join("\n"),
218 lines.len() - 20
219 );
220 }
221
222 format!(
223 "{} direct deps ({} total lines):\n{}",
224 direct.len(),
225 lines.len(),
226 direct.join("\n")
227 )
228}
229
230fn compress_fmt(output: &str) -> String {
231 let trimmed = output.trim();
232 if trimmed.is_empty() {
233 return "ok (formatted)".to_string();
234 }
235
236 let diffs: Vec<&str> = trimmed
237 .lines()
238 .filter(|l| l.starts_with("Diff in ") || l.starts_with(" --> "))
239 .collect();
240
241 if !diffs.is_empty() {
242 return format!("{} formatting issues:\n{}", diffs.len(), diffs.join("\n"));
243 }
244
245 let lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
246 if lines.len() <= 5 {
247 lines.join("\n")
248 } else {
249 format!(
250 "{}\n... ({} more lines)",
251 lines[..5].join("\n"),
252 lines.len() - 5
253 )
254 }
255}
256
257fn compress_update(output: &str) -> String {
258 let mut updated = Vec::new();
259 let mut unchanged = 0u32;
260
261 for line in output.lines() {
262 let trimmed = line.trim();
263 if trimmed.starts_with("Updating ") || trimmed.starts_with(" Updating ") {
264 updated.push(trimmed.trim_start_matches(" ").to_string());
265 } else if trimmed.starts_with("Unchanged ") || trimmed.contains("Unchanged") {
266 unchanged += 1;
267 }
268 }
269
270 if updated.is_empty() && unchanged == 0 {
271 let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
272 if lines.is_empty() {
273 return "ok (up-to-date)".to_string();
274 }
275 if lines.len() <= 5 {
276 return lines.join("\n");
277 }
278 return format!(
279 "{}\n... ({} more lines)",
280 lines[..5].join("\n"),
281 lines.len() - 5
282 );
283 }
284
285 let mut parts = Vec::new();
286 if !updated.is_empty() {
287 parts.push(format!("{} updated:", updated.len()));
288 for u in updated.iter().take(15) {
289 parts.push(format!(" {u}"));
290 }
291 if updated.len() > 15 {
292 parts.push(format!(" ... +{} more", updated.len() - 15));
293 }
294 }
295 if unchanged > 0 {
296 parts.push(format!("{unchanged} unchanged"));
297 }
298 parts.join("\n")
299}
300
301fn compress_metadata(output: &str) -> String {
302 let parsed: Result<serde_json::Value, _> = serde_json::from_str(output);
303 let json = match parsed {
304 Ok(v) => v,
305 Err(_) => {
306 let lines: Vec<&str> = output.lines().collect();
307 if lines.len() <= 20 {
308 return output.to_string();
309 }
310 return format!(
311 "{}\n... ({} more lines, non-JSON metadata)",
312 lines[..10].join("\n"),
313 lines.len() - 10
314 );
315 }
316 };
317
318 let mut parts = Vec::new();
319
320 if let Some(workspace_members) = json.get("workspace_members").and_then(|v| v.as_array()) {
321 parts.push(format!("workspace_members: {}", workspace_members.len()));
322 for m in workspace_members.iter().take(20) {
323 if let Some(s) = m.as_str() {
324 let short = s.split(' ').take(2).collect::<Vec<_>>().join(" ");
325 parts.push(format!(" {short}"));
326 }
327 }
328 if workspace_members.len() > 20 {
329 parts.push(format!(" ... +{} more", workspace_members.len() - 20));
330 }
331 }
332
333 if let Some(target_dir) = json.get("target_directory").and_then(|v| v.as_str()) {
334 parts.push(format!("target_directory: {target_dir}"));
335 }
336
337 if let Some(workspace_root) = json.get("workspace_root").and_then(|v| v.as_str()) {
338 parts.push(format!("workspace_root: {workspace_root}"));
339 }
340
341 if let Some(packages) = json.get("packages").and_then(|v| v.as_array()) {
342 parts.push(format!("packages: {}", packages.len()));
343 for pkg in packages.iter().take(30) {
344 let name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("?");
345 let version = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?");
346 let features: Vec<&str> = pkg
347 .get("features")
348 .and_then(|v| v.as_object())
349 .map(|f| f.keys().map(|k| k.as_str()).collect())
350 .unwrap_or_default();
351 if features.is_empty() {
352 parts.push(format!(" {name} v{version}"));
353 } else {
354 parts.push(format!(
355 " {name} v{version} [features: {}]",
356 features.join(", ")
357 ));
358 }
359 }
360 if packages.len() > 30 {
361 parts.push(format!(" ... +{} more", packages.len() - 30));
362 }
363 }
364
365 if let Some(resolve) = json.get("resolve") {
366 if let Some(nodes) = resolve.get("nodes").and_then(|v| v.as_array()) {
367 let total_deps: usize = nodes
368 .iter()
369 .map(|n| {
370 n.get("deps")
371 .and_then(|v| v.as_array())
372 .map(|a| a.len())
373 .unwrap_or(0)
374 })
375 .sum();
376 parts.push(format!(
377 "resolve: {} nodes, {} dep edges",
378 nodes.len(),
379 total_deps
380 ));
381 }
382 }
383
384 if parts.is_empty() {
385 "cargo metadata: ok (empty)".to_string()
386 } else {
387 parts.join("\n")
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn cargo_build_success() {
397 let output = " Compiling lean-ctx v2.1.1\n Finished release profile [optimized] target(s) in 30.5s";
398 let result = compress("cargo build", output).unwrap();
399 assert!(result.contains("compiled"), "should mention compilation");
400 assert!(result.contains("30.5s"), "should include build time");
401 }
402
403 #[test]
404 fn cargo_build_with_errors() {
405 let output = " Compiling lean-ctx v2.1.1\nerror[E0308]: mismatched types\n --> src/main.rs:10:5\n |\n10| 1 + \"hello\"\n | ^^^^^^^ expected integer, found &str";
406 let result = compress("cargo build", output).unwrap();
407 assert!(result.contains("E0308"), "should contain error code");
408 }
409
410 #[test]
411 fn cargo_test_success() {
412 let output = "running 5 tests\ntest test_one ... ok\ntest test_two ... ok\ntest test_three ... ok\ntest test_four ... ok\ntest test_five ... ok\n\ntest result: ok. 5 passed; 0 failed; 0 ignored";
413 let result = compress("cargo test", output).unwrap();
414 assert!(result.contains("5 pass"), "should show passed count");
415 }
416
417 #[test]
418 fn cargo_test_failure() {
419 let output = "running 3 tests\ntest test_ok ... ok\ntest test_fail ... FAILED\ntest test_ok2 ... ok\n\ntest result: FAILED. 2 passed; 1 failed; 0 ignored";
420 let result = compress("cargo test", output).unwrap();
421 assert!(result.contains("FAIL"), "should indicate failure");
422 }
423
424 #[test]
425 fn cargo_clippy_clean() {
426 let output = " Checking lean-ctx v2.1.1\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.2s";
427 let result = compress("cargo clippy", output).unwrap();
428 assert!(result.contains("clean"), "clean clippy should say clean");
429 }
430
431 #[test]
432 fn cargo_check_routes_to_build() {
433 let output = " Checking lean-ctx v2.1.1\n Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.1s";
434 let result = compress("cargo check", output);
435 assert!(
436 result.is_some(),
437 "cargo check should route to build compressor"
438 );
439 }
440
441 #[test]
442 fn cargo_metadata_json() {
443 let json = r#"{
444 "packages": [
445 {"name": "lean-ctx", "version": "3.2.9", "features": {"tree-sitter": ["dep:tree-sitter"]}},
446 {"name": "serde", "version": "1.0.200", "features": {"derive": ["serde_derive"]}}
447 ],
448 "workspace_members": ["lean-ctx 3.2.9 (path+file:///foo)"],
449 "workspace_root": "/foo",
450 "target_directory": "/foo/target",
451 "resolve": {
452 "nodes": [
453 {"id": "lean-ctx", "deps": [{"name": "serde"}]},
454 {"id": "serde", "deps": []}
455 ]
456 }
457 }"#;
458 let result = compress("cargo metadata", json).unwrap();
459 assert!(
460 result.contains("workspace_members: 1"),
461 "should list workspace members"
462 );
463 assert!(result.contains("packages: 2"), "should list packages");
464 assert!(
465 result.contains("resolve: 2 nodes"),
466 "should summarize resolve graph"
467 );
468 assert!(
469 result.len() < json.len(),
470 "compressed output should be shorter"
471 );
472 }
473
474 #[test]
475 fn cargo_metadata_non_json() {
476 let output = "error: `cargo metadata` exited with an error\nsome detailed error";
477 let result = compress("cargo metadata", output).unwrap();
478 assert!(
479 result.contains("error"),
480 "should pass through non-JSON output"
481 );
482 }
483}