lean_ctx/core/patterns/
npm.rs1use 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 let mut passed = 0u32;
120 let mut failed = 0u32;
121 let mut skipped = 0u32;
122
123 for line in output.lines() {
124 let trimmed = line.trim().to_lowercase();
125 if trimmed.contains("pass") {
126 passed += 1;
127 }
128 if trimmed.contains("fail") {
129 failed += 1;
130 }
131 if trimmed.contains("skip") || trimmed.contains("pending") {
132 skipped += 1;
133 }
134 }
135
136 format!("tests: {passed} pass, {failed} fail, {skipped} skip")
137}
138
139fn compress_audit(output: &str) -> String {
140 let mut severities = std::collections::HashMap::new();
141 let mut total_vulns = 0u32;
142
143 for line in output.lines() {
144 if let Some(caps) = vuln_re().captures(line) {
145 let count: u32 = caps[1].parse().unwrap_or(0);
146 let severity = caps[2].to_string();
147 *severities.entry(severity).or_insert(0u32) += count;
148 total_vulns += count;
149 }
150 }
151
152 if total_vulns == 0 {
153 if output.to_lowercase().contains("no vulnerabilities") || output.trim().is_empty() {
154 return "ok (0 vulnerabilities)".to_string();
155 }
156 return compact_output(output, 5);
157 }
158
159 let mut parts = Vec::new();
160 for sev in &["critical", "high", "moderate", "low"] {
161 if let Some(count) = severities.get(*sev) {
162 parts.push(format!("{count} {sev}"));
163 }
164 }
165 format!("{total_vulns} vulnerabilities: {}", parts.join(", "))
166}
167
168fn compress_outdated(output: &str) -> String {
169 let lines: Vec<&str> = output.lines().collect();
170 if lines.len() <= 1 {
171 return "all up-to-date".to_string();
172 }
173
174 let mut packages = Vec::new();
175 for line in &lines[1..] {
176 if let Some(caps) = outdated_re().captures(line) {
177 let name = &caps[1];
178 let current = &caps[2];
179 let wanted = &caps[3];
180 let latest = &caps[4];
181 packages.push(format!("{name}: {current} → {latest} (wanted: {wanted})"));
182 }
183 }
184
185 if packages.is_empty() {
186 return "all up-to-date".to_string();
187 }
188 format!("{} outdated:\n{}", packages.len(), packages.join("\n"))
189}
190
191fn compress_list(output: &str) -> String {
192 let lines: Vec<&str> = output.lines().collect();
193 if lines.len() <= 5 {
194 return output.to_string();
195 }
196
197 let top_level: Vec<&str> = lines
198 .iter()
199 .filter(|l| {
200 l.starts_with("├──")
201 || l.starts_with("└──")
202 || l.starts_with("+--")
203 || l.starts_with("`--")
204 })
205 .copied()
206 .collect();
207
208 if top_level.is_empty() {
209 return compact_output(output, 10);
210 }
211
212 let cleaned: Vec<String> = top_level
213 .iter()
214 .map(|l| {
215 l.replace("├──", "")
216 .replace("└──", "")
217 .replace("+--", "")
218 .replace("`--", "")
219 .trim()
220 .to_string()
221 })
222 .collect();
223
224 format!("{} packages:\n{}", cleaned.len(), cleaned.join("\n"))
225}
226
227fn compact_output(text: &str, max: usize) -> String {
228 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
229 if lines.len() <= max {
230 return lines.join("\n");
231 }
232 format!(
233 "{}\n... ({} more lines)",
234 lines[..max].join("\n"),
235 lines.len() - max
236 )
237}