Skip to main content

aft/compress/
pnpm.rs

1use crate::compress::generic::GenericCompressor;
2use crate::compress::{CompressionResult, Compressor, Specificity};
3
4pub struct PnpmCompressor;
5
6impl Compressor for PnpmCompressor {
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 == "pnpm")
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 pnpm_subcommand(command).as_deref() {
25            Some("install" | "i" | "add" | "remove") => {
26                preserve_pnpm_failure(output, compress_package(output)).into()
27            }
28            Some("run" | "test" | "build") => GenericCompressor::compress_output(output).into(),
29            _ => GenericCompressor::compress_output(output).into(),
30        }
31    }
32}
33
34/// Known pnpm subcommands. Same rationale as bun.rs::BUN_SUBCOMMANDS —
35/// using a whitelist instead of "first non-flag" avoids returning flag
36/// values like `--filter <pattern>` as the subcommand for command lines
37/// such as `pnpm --filter ./packages/foo test`.
38const PNPM_SUBCOMMANDS: &[&str] = &[
39    "install",
40    "i",
41    "add",
42    "remove",
43    "rm",
44    "uninstall",
45    "un",
46    "update",
47    "up",
48    "upgrade",
49    "outdated",
50    "audit",
51    "outdated-of",
52    "publish",
53    "pack",
54    "run",
55    "test",
56    "t",
57    "exec",
58    "x",
59    "dlx",
60    "create",
61    "init",
62    "build",
63    "start",
64    "link",
65    "unlink",
66    "view",
67    "info",
68    "show",
69    "config",
70    "help",
71    "version",
72    "ls",
73    "list",
74    "list-modules",
75    "list-bin",
76    "ping",
77    "whoami",
78    "login",
79    "logout",
80    "deploy",
81    "dedupe",
82    "fetch",
83    "import",
84    "patch",
85    "patch-commit",
86    "patch-remove",
87    "prune",
88    "rebuild",
89    "recursive",
90    "root",
91    "store",
92    "why",
93    "doctor",
94    "env",
95    "server",
96    "setup",
97];
98
99fn pnpm_subcommand(command: &str) -> Option<String> {
100    command
101        .split_whitespace()
102        .skip_while(|token| *token != "pnpm")
103        .skip(1)
104        .find(|token| PNPM_SUBCOMMANDS.contains(token))
105        .map(ToString::to_string)
106}
107
108fn preserve_pnpm_failure(output: &str, compressed: String) -> String {
109    let stripped_failure = compressed.trim().is_empty()
110        || !super::text_has_failure_signal(&compressed)
111        || !super::missing_raw_failure_signal_lines(output, &compressed).is_empty();
112    if !output.trim().is_empty() && super::text_has_failure_signal(output) && stripped_failure {
113        GenericCompressor::compress_output(output)
114    } else {
115        compressed
116    }
117}
118
119fn compress_package(output: &str) -> String {
120    let mut result = Vec::new();
121    let mut progress_seen = 0usize;
122    let mut up_to_date_seen = false;
123
124    for line in output.lines() {
125        let trimmed = line.trim_start();
126        if trimmed.starts_with("Progress: resolved ") {
127            progress_seen += 1;
128            if progress_seen > 2 {
129                continue;
130            }
131        }
132        if trimmed == "Already up-to-date" {
133            if up_to_date_seen {
134                continue;
135            }
136            up_to_date_seen = true;
137        }
138        if trimmed.contains("WARN GET_NO_AUTH")
139            || trimmed.starts_with("ERR_PNPM_")
140            || trimmed.starts_with("Progress: resolved ")
141            || trimmed == "Already up-to-date"
142            || trimmed.starts_with("dependencies:")
143            || trimmed.starts_with("devDependencies:")
144            || trimmed.starts_with("Done in ")
145        {
146            result.push(line.to_string());
147        }
148    }
149
150    trim_trailing_lines(&result.join("\n"))
151}
152
153fn trim_trailing_lines(input: &str) -> String {
154    input
155        .lines()
156        .map(str::trim_end)
157        .collect::<Vec<_>>()
158        .join("\n")
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn pnpm_install_lifecycle_error_does_not_compress_to_empty() {
167        let output = "Progress: resolved 1, reused 0, downloaded 0, added 0\n. postinstall$ node scripts/postinstall.js\n. postinstall: Error: Cannot find module 'sharp'\nELIFECYCLE Command failed with exit code 1\n";
168
169        let compressed = PnpmCompressor.compress("pnpm install", output);
170
171        assert!(!compressed.text.trim().is_empty());
172        assert!(compressed.text.contains("ELIFECYCLE"));
173        assert!(compressed.text.contains("Cannot find module"));
174    }
175}