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