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
34const 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}