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