Skip to main content

lean_ctx/core/patterns/
npm.rs

1use regex::Regex;
2use std::sync::OnceLock;
3
4static ADDED_RE: OnceLock<Regex> = OnceLock::new();
5static TIME_RE: OnceLock<Regex> = OnceLock::new();
6static PKG_RE: OnceLock<Regex> = OnceLock::new();
7static VULN_RE: OnceLock<Regex> = OnceLock::new();
8static OUTDATED_RE: OnceLock<Regex> = OnceLock::new();
9
10fn added_re() -> &'static Regex {
11    ADDED_RE.get_or_init(|| Regex::new(r"added (\d+) packages?").unwrap())
12}
13fn time_re() -> &'static Regex {
14    TIME_RE.get_or_init(|| Regex::new(r"in (\d+\.?\d*\s*[ms]+)").unwrap())
15}
16fn pkg_re() -> &'static Regex {
17    PKG_RE.get_or_init(|| Regex::new(r"\+ (\S+)@(\S+)").unwrap())
18}
19fn vuln_re() -> &'static Regex {
20    VULN_RE.get_or_init(|| Regex::new(r"(\d+)\s+(critical|high|moderate|low)").unwrap())
21}
22fn outdated_re() -> &'static Regex {
23    OUTDATED_RE.get_or_init(|| Regex::new(r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)").unwrap())
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() <= 5 {
111        return lines.join("\n");
112    }
113
114    let last = lines.len().saturating_sub(3);
115    format!("...({} lines)\n{}", lines.len(), lines[last..].join("\n"))
116}
117
118fn compress_test(output: &str) -> String {
119    static JEST_SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
120    static VITEST_SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
121    static MOCHA_SUMMARY_RE: OnceLock<Regex> = OnceLock::new();
122    static TEST_LINE_RE: OnceLock<Regex> = OnceLock::new();
123
124    let jest_re = JEST_SUMMARY_RE
125        .get_or_init(|| Regex::new(r"Tests:\s+(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+skipped,?\s*)?(?:(\d+)\s+passed,?\s*)?(\d+)\s+total").unwrap());
126    let vitest_re = VITEST_SUMMARY_RE.get_or_init(|| {
127        Regex::new(
128            r"Test Files\s+(?:(\d+)\s+failed\s*\|?\s*)?(?:(\d+)\s+passed\s*\|?\s*)?(\d+)\s+total",
129        )
130        .unwrap()
131    });
132    let mocha_re = MOCHA_SUMMARY_RE
133        .get_or_init(|| Regex::new(r"(\d+)\s+passing.*\n\s*(?:(\d+)\s+failing)?").unwrap());
134    let test_line_re =
135        TEST_LINE_RE.get_or_init(|| Regex::new(r"^\s*(✓|✗|✘|×|PASS|FAIL|ok|not ok)\s").unwrap());
136
137    for line in output.lines() {
138        if let Some(caps) = jest_re.captures(line) {
139            let failed: u32 = caps
140                .get(1)
141                .and_then(|m| m.as_str().parse().ok())
142                .unwrap_or(0);
143            let skipped: u32 = caps
144                .get(2)
145                .and_then(|m| m.as_str().parse().ok())
146                .unwrap_or(0);
147            let passed: u32 = caps
148                .get(3)
149                .and_then(|m| m.as_str().parse().ok())
150                .unwrap_or(0);
151            let total: u32 = caps
152                .get(4)
153                .and_then(|m| m.as_str().parse().ok())
154                .unwrap_or(0);
155            return format!("tests: {passed} pass, {failed} fail, {skipped} skip ({total} total)");
156        }
157        if let Some(caps) = vitest_re.captures(line) {
158            let failed: u32 = caps
159                .get(1)
160                .and_then(|m| m.as_str().parse().ok())
161                .unwrap_or(0);
162            let passed: u32 = caps
163                .get(2)
164                .and_then(|m| m.as_str().parse().ok())
165                .unwrap_or(0);
166            let total: u32 = caps
167                .get(3)
168                .and_then(|m| m.as_str().parse().ok())
169                .unwrap_or(0);
170            return format!("tests: {passed} pass, {failed} fail ({total} total)");
171        }
172    }
173
174    if let Some(caps) = mocha_re.captures(output) {
175        let passed: u32 = caps
176            .get(1)
177            .and_then(|m| m.as_str().parse().ok())
178            .unwrap_or(0);
179        let failed: u32 = caps
180            .get(2)
181            .and_then(|m| m.as_str().parse().ok())
182            .unwrap_or(0);
183        return format!("tests: {passed} pass, {failed} fail");
184    }
185
186    let mut passed = 0u32;
187    let mut failed = 0u32;
188    for line in output.lines() {
189        let trimmed = line.trim();
190        if test_line_re.is_match(trimmed) {
191            let low = trimmed.to_lowercase();
192            if low.starts_with("✓") || low.starts_with("pass") || low.starts_with("ok ") {
193                passed += 1;
194            } else {
195                failed += 1;
196            }
197        }
198    }
199
200    if passed > 0 || failed > 0 {
201        return format!("tests: {passed} pass, {failed} fail");
202    }
203
204    compact_output(output, 10)
205}
206
207fn compress_audit(output: &str) -> String {
208    let mut severities = std::collections::HashMap::new();
209    let mut total_vulns = 0u32;
210
211    for line in output.lines() {
212        if let Some(caps) = vuln_re().captures(line) {
213            let count: u32 = caps[1].parse().unwrap_or(0);
214            let severity = caps[2].to_string();
215            *severities.entry(severity).or_insert(0u32) += count;
216            total_vulns += count;
217        }
218    }
219
220    if total_vulns == 0 {
221        if output.to_lowercase().contains("no vulnerabilities") || output.trim().is_empty() {
222            return "ok (0 vulnerabilities)".to_string();
223        }
224        return compact_output(output, 5);
225    }
226
227    let mut parts = Vec::new();
228    for sev in &["critical", "high", "moderate", "low"] {
229        if let Some(count) = severities.get(*sev) {
230            parts.push(format!("{count} {sev}"));
231        }
232    }
233    format!("{total_vulns} vulnerabilities: {}", parts.join(", "))
234}
235
236fn compress_outdated(output: &str) -> String {
237    let lines: Vec<&str> = output.lines().collect();
238    if lines.len() <= 1 {
239        return "all up-to-date".to_string();
240    }
241
242    let mut packages = Vec::new();
243    for line in &lines[1..] {
244        if let Some(caps) = outdated_re().captures(line) {
245            let name = &caps[1];
246            let current = &caps[2];
247            let wanted = &caps[3];
248            let latest = &caps[4];
249            packages.push(format!("{name}: {current} → {latest} (wanted: {wanted})"));
250        }
251    }
252
253    if packages.is_empty() {
254        return "all up-to-date".to_string();
255    }
256    format!("{} outdated:\n{}", packages.len(), packages.join("\n"))
257}
258
259fn compress_list(output: &str) -> String {
260    let lines: Vec<&str> = output.lines().collect();
261    if lines.len() <= 5 {
262        return output.to_string();
263    }
264
265    let top_level: Vec<&str> = lines
266        .iter()
267        .filter(|l| {
268            l.starts_with("├──")
269                || l.starts_with("└──")
270                || l.starts_with("+--")
271                || l.starts_with("`--")
272        })
273        .copied()
274        .collect();
275
276    if top_level.is_empty() {
277        return compact_output(output, 10);
278    }
279
280    let cleaned: Vec<String> = top_level
281        .iter()
282        .map(|l| {
283            l.replace("├──", "")
284                .replace("└──", "")
285                .replace("+--", "")
286                .replace("`--", "")
287                .trim()
288                .to_string()
289        })
290        .collect();
291
292    format!("{} packages:\n{}", cleaned.len(), cleaned.join("\n"))
293}
294
295fn compact_output(text: &str, max: usize) -> String {
296    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
297    if lines.len() <= max {
298        return lines.join("\n");
299    }
300    format!(
301        "{}\n... ({} more lines)",
302        lines[..max].join("\n"),
303        lines.len() - max
304    )
305}