Skip to main content

aft/compress/
next.rs

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}