Skip to main content

lean_ctx/core/patterns/
npm.rs

1macro_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}