1use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
2use crate::compress::Compressor;
3
4const MAX_LINES: usize = 500;
5
6pub struct NextCompressor;
7
8impl Compressor for NextCompressor {
9 fn matches(&self, command: &str) -> bool {
10 let tokens: Vec<String> = command_tokens(command).collect();
11 tokens.iter().any(|token| token == "next") && tokens.iter().any(|token| token == "build")
12 }
13
14 fn compress(&self, _command: &str, output: &str) -> String {
15 compress_next(output)
16 }
17}
18
19fn compress_next(output: &str) -> String {
20 let stripped = strip_ansi(output);
21 if has_build_error(&stripped) {
22 return finish(&error_block(&stripped));
23 }
24
25 let extracted = extract_route_table(&stripped);
26 if extracted.trim().is_empty() {
27 return GenericCompressor::compress_output(&drop_noise(&stripped));
28 }
29
30 finish(&extracted)
31}
32
33fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
34 command
35 .split_whitespace()
36 .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
37 .map(|token| {
38 token
39 .rsplit(['/', '\\'])
40 .next()
41 .unwrap_or(token)
42 .trim_end_matches(".cmd")
43 .to_string()
44 })
45}
46
47fn has_build_error(output: &str) -> bool {
48 output.contains("Failed to compile")
49 || output.contains("Type error:")
50 || output.contains("SyntaxError:")
51 || output
52 .lines()
53 .any(|line| line.trim_start().starts_with("Error:"))
54}
55
56fn error_block(output: &str) -> String {
57 let lines: Vec<&str> = output.lines().collect();
58 let start = lines
59 .iter()
60 .position(|line| is_error_line(line.trim_start()))
61 .unwrap_or(0);
62 lines[start..].join("\n")
63}
64
65fn is_error_line(trimmed: &str) -> bool {
66 trimmed.contains("Failed to compile")
67 || trimmed.starts_with("Error:")
68 || trimmed.starts_with("Type error:")
69 || trimmed.starts_with("SyntaxError:")
70}
71
72fn extract_route_table(output: &str) -> String {
73 let mut kept = Vec::new();
74 let mut in_table = false;
75 let mut saw_table = false;
76
77 for line in output.lines() {
78 let trimmed = line.trim_start();
79 if !in_table {
80 if trimmed.starts_with("Route (") {
81 in_table = true;
82 saw_table = true;
83 kept.push(line.to_string());
84 }
85 continue;
86 }
87
88 if should_preserve_route_line(trimmed) {
89 kept.push(line.to_string());
90 } else if saw_table && !trimmed.is_empty() {
91 break;
92 } else if trimmed.is_empty() {
93 kept.push(line.to_string());
94 }
95 }
96
97 trim_blank_edges(kept).join("\n")
98}
99
100fn should_preserve_route_line(trimmed: &str) -> bool {
101 trimmed.starts_with("Route (")
102 || trimmed.starts_with('┌')
103 || trimmed.starts_with('├')
104 || trimmed.starts_with('└')
105 || trimmed.starts_with("+ First Load JS shared by all")
106 || trimmed.starts_with("○ (")
107 || trimmed.starts_with("ƒ (")
108 || trimmed.starts_with("● (")
109 || trimmed.starts_with("◐ (")
110 || trimmed.starts_with("λ (")
111 || trimmed.starts_with("ƒ")
112 || trimmed.starts_with("○")
113 || trimmed.starts_with("●")
114 || trimmed.starts_with("◐")
115 || trimmed.starts_with("λ")
116 || trimmed.starts_with('┬')
117 || trimmed.starts_with('│')
118 || trimmed.starts_with("└ ")
119 || trimmed.starts_with("┌ ")
120 || trimmed.starts_with("├ ")
121 || trimmed.starts_with("└ other shared chunks")
122 || trimmed.starts_with("├ chunks/")
123 || trimmed.starts_with("└ chunks/")
124 || trimmed.starts_with("├ ")
125 || trimmed.starts_with("└ ")
126}
127
128fn drop_noise(output: &str) -> String {
129 output
130 .lines()
131 .filter(|line| !is_noise_line(line.trim()))
132 .collect::<Vec<_>>()
133 .join("\n")
134}
135
136fn is_noise_line(trimmed: &str) -> bool {
137 trimmed.starts_with("Attention: Next.js now collects")
138 || trimmed.starts_with("Learn more:")
139 || trimmed.starts_with("▲ Next.js")
140 || trimmed.starts_with("Creating an optimized production build")
141 || trimmed.starts_with("✓ ")
142}
143
144fn trim_blank_edges(lines: Vec<String>) -> Vec<String> {
145 let start = lines
146 .iter()
147 .position(|line| !line.trim().is_empty())
148 .unwrap_or(lines.len());
149 let end = lines
150 .iter()
151 .rposition(|line| !line.trim().is_empty())
152 .map_or(start, |index| index + 1);
153 lines[start..end].to_vec()
154}
155
156fn finish(input: &str) -> String {
157 let deduped = dedup_consecutive(input);
158 cap_lines(
159 &middle_truncate(&deduped, 64 * 1024, 32 * 1024, 32 * 1024),
160 MAX_LINES,
161 )
162}
163
164fn cap_lines(input: &str, max_lines: usize) -> String {
165 let lines: Vec<&str> = input.lines().collect();
166 if lines.len() <= max_lines {
167 return input.trim_end().to_string();
168 }
169 let mut kept = lines
170 .iter()
171 .take(max_lines)
172 .copied()
173 .collect::<Vec<_>>()
174 .join("\n");
175 kept.push_str(&format!(
176 "\n... truncated {} lines",
177 lines.len() - max_lines
178 ));
179 kept
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn sample_success_output() -> &'static str {
187 r#"Attention: Next.js now collects completely anonymous telemetry...
188
189▲ Next.js 15.2.0
190
191 Creating an optimized production build ...
192 ✓ Compiled successfully
193 ✓ Linting and checking validity of types
194 ✓ Collecting page data
195 ✓ Generating static pages (12/12)
196 ✓ Collecting build traces
197 ✓ Finalizing page optimization
198
199Route (app) Size First Load JS
200┌ ○ / 142 B 85.5 kB
201├ ○ /_not-found 885 B 82.5 kB
202├ ○ /about 142 B 85.5 kB
203├ ƒ /api/health 0 B 0 B
204└ ƒ /dashboard 1.2 kB 92.0 kB
205+ First Load JS shared by all 82.4 kB
206 ├ chunks/2117-abc.js 29.6 kB
207 └ other shared chunks (total) 52.7 kB
208
209○ (Static) prerendered as static content
210ƒ (Dynamic) server-rendered on demand
211"#
212 }
213
214 #[test]
215 fn matches_next_build_token_anywhere_and_rejects_substrings() {
216 let compressor = NextCompressor;
217 assert!(compressor.matches("next build"));
218 assert!(compressor.matches("npx next build"));
219 assert!(compressor.matches("pnpm exec next build"));
220 assert!(compressor.matches("bun run next build"));
221 assert!(compressor.matches("./node_modules/.bin/next build"));
222 assert!(!compressor.matches("next dev"));
223 assert!(!compressor.matches("next-i18n-router build"));
224 assert!(!compressor.matches("pingnext build"));
225 }
226
227 #[test]
228 fn happy_path_drops_noise_and_preserves_route_table() {
229 let compressed = compress_next(sample_success_output());
230
231 assert!(compressed.starts_with("Route (app)"));
232 assert!(compressed.contains("└ ƒ /dashboard"));
233 assert!(compressed.contains("+ First Load JS shared by all"));
234 assert!(compressed.contains("○ (Static)"));
235 assert!(compressed.contains("ƒ (Dynamic)"));
236 assert!(!compressed.contains("telemetry"));
237 assert!(!compressed.contains("Next.js 15.2.0"));
238 assert!(!compressed.contains("Compiled successfully"));
239 }
240
241 #[test]
242 fn failure_path_preserves_error_block_verbatim() {
243 let error_block = r#"Failed to compile.
244
245./app/page.tsx:10:7
246Type error: Type 'string' is not assignable to type 'number'.
247
248 8 | export default function Page() {
249 9 | const count: number = 1;
250> 10 | const bad: number = "oops";
251 | ^
252 11 | return <main>{bad}</main>;
253"#;
254 let output = format!(
255 "Attention: Next.js now collects completely anonymous telemetry...\n▲ Next.js 15.2.0\n ✓ Compiled successfully\n{error_block}"
256 );
257
258 let compressed = compress_next(&output);
259
260 assert_eq!(compressed, error_block.trim_end());
261 assert!(compressed.contains("Type error:"));
262 assert!(!compressed.contains("Route (app)"));
263 }
264
265 #[test]
266 fn large_route_table_compresses_below_half_and_keeps_markers() {
267 let mut output = String::from(
268 "Attention: Next.js now collects completely anonymous telemetry...\n▲ Next.js 15.2.0\n Creating an optimized production build ...\n",
269 );
270 for index in 0..400 {
271 output.push_str(&format!(" ✓ Progress step {index}\n"));
272 }
273 output.push_str("\nRoute (app) Size First Load JS\n");
274 output.push_str("┌ ○ / 142 B 85.5 kB\n");
275 for index in 0..120 {
276 output.push_str(&format!("├ ○ /page-{index:<30} 142 B 85.5 kB\n"));
277 }
278 output.push_str("└ ƒ /dashboard 1.2 kB 92.0 kB\n");
279 output.push_str("+ First Load JS shared by all 82.4 kB\n ├ chunks/2117-abc.js 29.6 kB\n └ other shared chunks (total) 52.7 kB\n\n○ (Static) prerendered as static content\nƒ (Dynamic) server-rendered on demand\n");
280
281 let compressed = compress_next(&output);
282
283 assert!(compressed.len() < output.len() / 2);
284 assert!(compressed.contains("Route (app)"));
285 assert!(compressed.contains("/dashboard"));
286 assert!(compressed.contains("ƒ (Dynamic)"));
287 assert!(!compressed.contains("Progress step"));
288 }
289
290 #[test]
291 fn syntax_error_preserves_error_instead_of_extracting_table() {
292 let output = r#"▲ Next.js 15.2.0
293SyntaxError: Unexpected token '<'
294 at app/page.tsx:1:1
295
296Route (app) Size First Load JS
297┌ ○ / 142 B 85.5 kB
298"#;
299
300 let compressed = compress_next(output);
301
302 assert!(compressed.starts_with("SyntaxError: Unexpected token '<'"));
303 assert!(compressed.contains("at app/page.tsx:1:1"));
304 assert!(compressed.contains("Route (app)"));
305 }
306}