Skip to main content

aft/compress/
npm.rs

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
34/// Known npm subcommands. Same rationale as bun.rs::BUN_SUBCOMMANDS —
35/// using a whitelist instead of "first non-flag" avoids returning flag
36/// values like `--prefix <dir>` as the subcommand for command lines
37/// such as `npm --prefix packages/foo install`.
38const 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}