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