1use crate::compress::generic::GenericCompressor;
2use crate::compress::{CompressionResult, Compressor, Specificity};
3
4pub struct NpmCompressor;
5
6impl Compressor for NpmCompressor {
7 fn specificity(&self) -> Specificity {
8 Specificity::PackageManager
9 }
10
11 fn matches(&self, command: &str) -> bool {
12 command
13 .split_whitespace()
14 .next()
15 .is_some_and(|head| head == "npm")
16 }
17
18 fn compress_with_exit_code(
19 &self,
20 command: &str,
21 output: &str,
22 _exit_code: Option<i32>,
23 ) -> CompressionResult {
24 match npm_subcommand(command).as_deref() {
25 Some("install" | "i" | "ci") => compress_install(output).into(),
26 Some("run" | "test") => GenericCompressor::compress_output(output).into(),
27 Some("audit") => compress_audit(output).into(),
28 Some("publish") => compress_install(output).into(),
29 _ => GenericCompressor::compress_output(output).into(),
30 }
31 }
32}
33
34const NPM_SUBCOMMANDS: &[&str] = &[
39 "install",
40 "i",
41 "ci",
42 "uninstall",
43 "remove",
44 "rm",
45 "update",
46 "up",
47 "audit",
48 "outdated",
49 "publish",
50 "pack",
51 "run",
52 "run-script",
53 "test",
54 "t",
55 "start",
56 "stop",
57 "restart",
58 "exec",
59 "x",
60 "init",
61 "create",
62 "build",
63 "link",
64 "unlink",
65 "view",
66 "info",
67 "show",
68 "config",
69 "help",
70 "version",
71 "search",
72 "ls",
73 "list",
74 "ping",
75 "whoami",
76 "login",
77 "logout",
78 "dedupe",
79 "dist-tag",
80 "team",
81 "owner",
82 "doctor",
83 "fund",
84 "explain",
85 "diff",
86 "rebuild",
87 "deprecate",
88 "hook",
89 "org",
90 "profile",
91 "set-script",
92 "pkg",
93];
94
95fn npm_subcommand(command: &str) -> Option<String> {
96 command
97 .split_whitespace()
98 .skip_while(|token| *token != "npm")
99 .skip(1)
100 .find(|token| NPM_SUBCOMMANDS.contains(token))
101 .map(ToString::to_string)
102}
103
104fn compress_install(output: &str) -> String {
105 let lines: Vec<&str> = output.lines().collect();
106 let has_audit = lines
107 .iter()
108 .any(|line| line.trim_start().starts_with("audited "));
109 let has_final_summary = lines.iter().any(|line| is_final_summary(line));
110 let tail_start = lines.len().saturating_sub(5);
111 let mut result = Vec::new();
112 let mut deprecated_seen = 0usize;
113 let mut deprecated_omitted = 0usize;
114
115 for (index, line) in lines.iter().enumerate() {
116 if is_npm_progress(line) {
117 continue;
118 }
119 if line.trim_start().starts_with("npm WARN deprecated ") {
120 deprecated_seen += 1;
121 if deprecated_seen <= 5 {
122 result.push((*line).to_string());
123 } else {
124 deprecated_omitted += 1;
125 }
126 continue;
127 }
128 if has_audit && has_final_summary && line.trim_start().starts_with("added ") {
129 continue;
130 }
131 if index >= tail_start
132 || line.trim_start().starts_with("npm ERR!")
133 || is_final_summary(line)
134 {
135 result.push((*line).to_string());
136 }
137 }
138
139 if deprecated_omitted > 0 {
140 insert_after_deprecations(
141 &mut result,
142 format!("... and {deprecated_omitted} more deprecation warnings"),
143 );
144 }
145
146 trim_trailing_lines(&result.join("\n"))
147}
148
149fn is_npm_progress(line: &str) -> bool {
150 let trimmed = line.trim_start();
151 trimmed.starts_with("npm http fetch")
152 || trimmed.starts_with("npm timing")
153 || trimmed.starts_with("npm verb")
154}
155
156fn is_final_summary(line: &str) -> bool {
157 let trimmed = line.trim_start();
158 trimmed.starts_with("audited ")
159 || trimmed.starts_with("found ")
160 || trimmed.contains(" vulnerabilities")
161 || trimmed.contains(" packages are looking for funding")
162 || trimmed.starts_with("published ")
163 || trimmed.starts_with("+ ")
164}
165
166fn insert_after_deprecations(result: &mut Vec<String>, summary: String) {
167 let position = result
168 .iter()
169 .rposition(|line| line.trim_start().starts_with("npm WARN deprecated "))
170 .map_or(0, |index| index + 1);
171 result.insert(position, summary);
172}
173
174fn compress_audit(output: &str) -> String {
175 let mut result = Vec::new();
176 let mut vulnerabilities = 0usize;
177 let mut omitted = 0usize;
178
179 for line in output.lines() {
180 if is_audit_vulnerability_line(line) {
181 vulnerabilities += 1;
182 if vulnerabilities <= 10 {
183 result.push(line.to_string());
184 } else {
185 omitted += 1;
186 }
187 continue;
188 }
189 if is_audit_summary(line) || vulnerabilities <= 10 {
190 result.push(line.to_string());
191 }
192 }
193
194 if omitted > 0 {
195 result.push(format!("... and {omitted} more vulnerabilities"));
196 }
197
198 trim_trailing_lines(&result.join("\n"))
199}
200
201fn is_audit_vulnerability_line(line: &str) -> bool {
202 let trimmed = line.trim_start();
203 trimmed.starts_with("VULN ") || trimmed.starts_with("# ") && trimmed.contains(" - ")
204}
205
206fn is_audit_summary(line: &str) -> bool {
207 let trimmed = line.trim_start();
208 trimmed.starts_with("found ")
209 || trimmed.starts_with("npm audit fix")
210 || trimmed.contains(" vulnerabilities")
211}
212
213fn trim_trailing_lines(input: &str) -> String {
214 input
215 .lines()
216 .map(str::trim_end)
217 .collect::<Vec<_>>()
218 .join("\n")
219}