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