1macro_rules! static_regex {
4 ($pattern:expr_2021) => {{
5 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6 RE.get_or_init(|| {
7 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8 })
9 }};
10}
11
12fn added_re() -> &'static regex::Regex {
13 static_regex!(r"added (\d+) packages?")
14}
15fn time_re() -> &'static regex::Regex {
16 static_regex!(r"in (\d+\.?\d*\s*[ms]+)")
17}
18fn pkg_re() -> &'static regex::Regex {
19 static_regex!(r"\+ (\S+)@(\S+)")
20}
21fn vuln_re() -> &'static regex::Regex {
22 static_regex!(r"(\d+)\s+(critical|high|moderate|low)")
23}
24fn outdated_re() -> &'static regex::Regex {
25 static_regex!(r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)")
26}
27
28pub fn compress(command: &str, output: &str) -> Option<String> {
29 if command.contains("install") || command.contains("add") || command.contains("ci") {
30 return Some(compress_install(output));
31 }
32 if command.contains("run") {
33 return Some(compress_run(output));
34 }
35 if command.contains("test") {
36 return Some(compress_test(output));
37 }
38 if command.contains("audit") {
39 return Some(compress_audit(output));
40 }
41 if command.contains("outdated") {
42 return Some(compress_outdated(output));
43 }
44 if command.contains("list") || command.contains("ls") {
45 return Some(compress_list(output));
46 }
47 None
48}
49
50fn compress_install(output: &str) -> String {
51 let mut packages = Vec::new();
52 let mut dep_count = 0u32;
53 let mut time = String::new();
54
55 for line in output.lines() {
56 if let Some(caps) = pkg_re().captures(line) {
57 packages.push(format!("{}@{}", &caps[1], &caps[2]));
58 }
59 if let Some(caps) = added_re().captures(line) {
60 dep_count = caps[1].parse().unwrap_or(0);
61 }
62 if let Some(caps) = time_re().captures(line) {
63 time = caps[1].to_string();
64 }
65 }
66
67 let pkg_str = if packages.is_empty() {
68 String::new()
69 } else {
70 format!("+{}", packages.join(", +"))
71 };
72
73 let dep_str = if dep_count > 0 {
74 format!(" ({dep_count} deps")
75 } else {
76 " (".to_string()
77 };
78
79 let time_str = if time.is_empty() {
80 ")".to_string()
81 } else {
82 format!(", {time})")
83 };
84
85 let summary = if pkg_str.is_empty() && dep_count > 0 {
86 format!(
87 "ok ({dep_count} deps{}",
88 if time.is_empty() {
89 ")".to_string()
90 } else {
91 format!(", {time})")
92 }
93 )
94 } else {
95 format!("{pkg_str}{dep_str}{time_str}")
96 };
97 with_install_advisories(summary, output)
98}
99
100pub(super) fn install_advisories(output: &str) -> Vec<String> {
104 let mut kept: Vec<String> = Vec::new();
105 for line in output.lines() {
106 let t = line.trim();
107 let lower = t.to_ascii_lowercase();
108 let is_vuln_summary = lower.contains("vulnerabilit")
109 && t.split_whitespace()
110 .any(|w| w.parse::<u32>().is_ok_and(|n| n > 0));
111 let is_fix_hint = lower.contains("audit fix") || lower.contains("audit --fix");
112 if (is_vuln_summary || is_fix_hint) && !kept.iter().any(|k| k == t) {
113 kept.push(t.to_string());
114 }
115 }
116 kept
117}
118
119pub(super) fn with_install_advisories(summary: String, output: &str) -> String {
120 let advisories = install_advisories(output);
121 if advisories.is_empty() {
122 summary
123 } else {
124 format!("{summary}\n{}", advisories.join("\n"))
125 }
126}
127
128fn compress_run(output: &str) -> String {
129 let lines: Vec<&str> = output
130 .lines()
131 .filter(|l| {
132 let t = l.trim();
133 !t.is_empty()
134 && !t.starts_with('>')
135 && !t.starts_with("npm warn")
136 && !t.contains("npm fund")
137 && !t.contains("looking for funding")
138 })
139 .collect();
140
141 if lines.len() <= 15 {
142 return lines.join("\n");
143 }
144
145 let last = lines.len().saturating_sub(10);
146 format!("...({} lines)\n{}", lines.len(), lines[last..].join("\n"))
147}
148
149fn compress_test(output: &str) -> String {
150 let jest_re = static_regex!(
151 r"Tests:\s+(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+skipped,?\s*)?(?:(\d+)\s+passed,?\s*)?(\d+)\s+total"
152 );
153 let vitest_re = static_regex!(
154 r"Test Files\s+(?:(\d+)\s+failed\s*\|?\s*)?(?:(\d+)\s+passed\s*\|?\s*)?(\d+)\s+total"
155 );
156 let mocha_re = static_regex!(r"(\d+)\s+passing.*\n\s*(?:(\d+)\s+failing)?");
157 let test_line_re = static_regex!(r"^\s*(✓|✗|✘|×|PASS|FAIL|ok|not ok)\s");
158
159 for line in output.lines() {
160 if let Some(caps) = jest_re.captures(line) {
161 let failed: u32 = caps
162 .get(1)
163 .and_then(|m| m.as_str().parse().ok())
164 .unwrap_or(0);
165 let skipped: u32 = caps
166 .get(2)
167 .and_then(|m| m.as_str().parse().ok())
168 .unwrap_or(0);
169 let passed: u32 = caps
170 .get(3)
171 .and_then(|m| m.as_str().parse().ok())
172 .unwrap_or(0);
173 let total: u32 = caps
174 .get(4)
175 .and_then(|m| m.as_str().parse().ok())
176 .unwrap_or(0);
177 return format!("tests: {passed} pass, {failed} fail, {skipped} skip ({total} total)");
178 }
179 if let Some(caps) = vitest_re.captures(line) {
180 let failed: u32 = caps
181 .get(1)
182 .and_then(|m| m.as_str().parse().ok())
183 .unwrap_or(0);
184 let passed: u32 = caps
185 .get(2)
186 .and_then(|m| m.as_str().parse().ok())
187 .unwrap_or(0);
188 let total: u32 = caps
189 .get(3)
190 .and_then(|m| m.as_str().parse().ok())
191 .unwrap_or(0);
192 return format!("tests: {passed} pass, {failed} fail ({total} total)");
193 }
194 }
195
196 if let Some(caps) = mocha_re.captures(output) {
197 let passed: u32 = caps
198 .get(1)
199 .and_then(|m| m.as_str().parse().ok())
200 .unwrap_or(0);
201 let failed: u32 = caps
202 .get(2)
203 .and_then(|m| m.as_str().parse().ok())
204 .unwrap_or(0);
205 return format!("tests: {passed} pass, {failed} fail");
206 }
207
208 let mut passed = 0u32;
209 let mut failed = 0u32;
210 for line in output.lines() {
211 let trimmed = line.trim();
212 if test_line_re.is_match(trimmed) {
213 let low = trimmed.to_lowercase();
214 if low.starts_with("✓") || low.starts_with("pass") || low.starts_with("ok ") {
215 passed += 1;
216 } else {
217 failed += 1;
218 }
219 }
220 }
221
222 if passed > 0 || failed > 0 {
223 return format!("tests: {passed} pass, {failed} fail");
224 }
225
226 compact_output(output, 10)
227}
228
229fn compress_audit(output: &str) -> String {
230 let mut severities = std::collections::HashMap::new();
231 let mut total_vulns = 0u32;
232 let mut detail_lines: Vec<String> = Vec::new();
233
234 for line in output.lines() {
235 if let Some(caps) = vuln_re().captures(line) {
236 let count: u32 = caps[1].parse().unwrap_or(0);
237 let severity = caps[2].to_string();
238 *severities.entry(severity).or_insert(0u32) += count;
239 total_vulns += count;
240 }
241
242 let lower = line.to_ascii_lowercase();
243 let is_detail = lower.contains("cve-")
244 || lower.contains("severity")
245 || lower.contains("fix available")
246 || lower.contains("package")
247 || lower.contains("depends on vulnerable")
248 || lower.contains("vulnerability")
249 || lower.contains("moderate")
250 || lower.contains("high")
251 || lower.contains("critical");
252 if is_detail && detail_lines.len() < 30 {
253 detail_lines.push(line.to_string());
254 }
255 }
256
257 if total_vulns == 0 {
258 if output.to_lowercase().contains("no vulnerabilities") || output.trim().is_empty() {
259 return "ok (0 vulnerabilities)".to_string();
260 }
261 return compact_output(output, 5);
262 }
263
264 let mut parts = Vec::new();
265 for sev in &["critical", "high", "moderate", "low"] {
266 if let Some(count) = severities.get(*sev) {
267 parts.push(format!("{count} {sev}"));
268 }
269 }
270
271 let summary = format!("{total_vulns} vulnerabilities: {}", parts.join(", "));
272 if detail_lines.is_empty() {
273 return summary;
274 }
275
276 format!("{summary}\n{}", detail_lines.join("\n"))
277}
278
279fn compress_outdated(output: &str) -> String {
280 let lines: Vec<&str> = output.lines().collect();
281 if lines.len() <= 1 {
282 return "all up-to-date".to_string();
283 }
284
285 let mut packages = Vec::new();
286 for line in &lines[1..] {
287 if let Some(caps) = outdated_re().captures(line) {
288 let name = &caps[1];
289 let current = &caps[2];
290 let wanted = &caps[3];
291 let latest = &caps[4];
292 packages.push(format!("{name}: {current} → {latest} (wanted: {wanted})"));
293 }
294 }
295
296 if packages.is_empty() {
297 return "all up-to-date".to_string();
298 }
299 format!("{} outdated:\n{}", packages.len(), packages.join("\n"))
300}
301
302fn compress_list(output: &str) -> String {
303 let lines: Vec<&str> = output.lines().collect();
304 if lines.len() <= 5 {
305 return output.to_string();
306 }
307
308 let top_level: Vec<&str> = lines
309 .iter()
310 .filter(|l| {
311 l.starts_with("├──")
312 || l.starts_with("└──")
313 || l.starts_with("+--")
314 || l.starts_with("`--")
315 })
316 .copied()
317 .collect();
318
319 if top_level.is_empty() {
320 return compact_output(output, 10);
321 }
322
323 let cleaned: Vec<String> = top_level
324 .iter()
325 .map(|l| {
326 l.replace("├──", "")
327 .replace("└──", "")
328 .replace("+--", "")
329 .replace("`--", "")
330 .trim()
331 .to_string()
332 })
333 .collect();
334
335 format!("{} packages:\n{}", cleaned.len(), cleaned.join("\n"))
336}
337
338fn compact_output(text: &str, max: usize) -> String {
339 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
340 if lines.len() <= max {
341 return lines.join("\n");
342 }
343 format!(
344 "{}\n{}",
345 lines[..max].join("\n"),
346 super::elision_marker(lines.len() - max)
347 )
348}
349
350#[cfg(test)]
351mod tests {
352 use super::compress;
353
354 #[test]
355 fn install_keeps_vulnerability_summary_and_fix_hint() {
356 let out = "added 847 packages, and audited 848 packages in 12s\n\n3 vulnerabilities (1 moderate, 2 high)\n\nTo address all issues, run:\n npm audit fix\n";
357 assert_eq!(
358 compress("npm install", out).unwrap(),
359 "ok (847 deps, 12s)\n3 vulnerabilities (1 moderate, 2 high)\nnpm audit fix"
360 );
361 }
362
363 #[test]
364 fn install_drops_zero_vulnerabilities() {
365 let out = "added 2 packages, and audited 3 packages in 1s\n\nfound 0 vulnerabilities\n";
366 assert_eq!(compress("npm ci", out).unwrap(), "ok (2 deps, 1s)");
367 }
368}