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