Skip to main content

aft/compress/
next.rs

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}