agent-file-tools 0.29.0

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
use crate::compress::Compressor;

const MAX_LINES: usize = 500;

pub struct NextCompressor;

impl Compressor for NextCompressor {
    fn matches(&self, command: &str) -> bool {
        let tokens: Vec<String> = command_tokens(command).collect();
        tokens.iter().any(|token| token == "next") && tokens.iter().any(|token| token == "build")
    }

    fn compress(&self, _command: &str, output: &str) -> String {
        compress_next(output)
    }
}

fn compress_next(output: &str) -> String {
    let stripped = strip_ansi(output);
    if has_build_error(&stripped) {
        return finish(&error_block(&stripped));
    }

    let extracted = extract_route_table(&stripped);
    if extracted.trim().is_empty() {
        return GenericCompressor::compress_output(&drop_noise(&stripped));
    }

    finish(&extracted)
}

fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
    command
        .split_whitespace()
        .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
        .map(|token| {
            token
                .rsplit(['/', '\\'])
                .next()
                .unwrap_or(token)
                .trim_end_matches(".cmd")
                .to_string()
        })
}

fn has_build_error(output: &str) -> bool {
    output.contains("Failed to compile")
        || output.contains("Type error:")
        || output.contains("SyntaxError:")
        || output
            .lines()
            .any(|line| line.trim_start().starts_with("Error:"))
}

fn error_block(output: &str) -> String {
    let lines: Vec<&str> = output.lines().collect();
    let start = lines
        .iter()
        .position(|line| is_error_line(line.trim_start()))
        .unwrap_or(0);
    lines[start..].join("\n")
}

fn is_error_line(trimmed: &str) -> bool {
    trimmed.contains("Failed to compile")
        || trimmed.starts_with("Error:")
        || trimmed.starts_with("Type error:")
        || trimmed.starts_with("SyntaxError:")
}

fn extract_route_table(output: &str) -> String {
    let mut kept = Vec::new();
    let mut in_table = false;
    let mut saw_table = false;

    for line in output.lines() {
        let trimmed = line.trim_start();
        if !in_table {
            if trimmed.starts_with("Route (") {
                in_table = true;
                saw_table = true;
                kept.push(line.to_string());
            }
            continue;
        }

        if should_preserve_route_line(trimmed) {
            kept.push(line.to_string());
        } else if saw_table && !trimmed.is_empty() {
            break;
        } else if trimmed.is_empty() {
            kept.push(line.to_string());
        }
    }

    trim_blank_edges(kept).join("\n")
}

fn should_preserve_route_line(trimmed: &str) -> bool {
    trimmed.starts_with("Route (")
        || trimmed.starts_with('')
        || trimmed.starts_with('')
        || trimmed.starts_with('')
        || trimmed.starts_with("+ First Load JS shared by all")
        || trimmed.starts_with("○  (")
        || trimmed.starts_with("ƒ  (")
        || trimmed.starts_with("●  (")
        || trimmed.starts_with("◐  (")
        || trimmed.starts_with("λ  (")
        || trimmed.starts_with("ƒ")
        || trimmed.starts_with("")
        || trimmed.starts_with("")
        || trimmed.starts_with("")
        || trimmed.starts_with("λ")
        || trimmed.starts_with('')
        || trimmed.starts_with('')
        || trimmed.starts_with("")
        || trimmed.starts_with("")
        || trimmed.starts_with("")
        || trimmed.starts_with("└ other shared chunks")
        || trimmed.starts_with("├ chunks/")
        || trimmed.starts_with("└ chunks/")
        || trimmed.starts_with("")
        || trimmed.starts_with("")
}

fn drop_noise(output: &str) -> String {
    output
        .lines()
        .filter(|line| !is_noise_line(line.trim()))
        .collect::<Vec<_>>()
        .join("\n")
}

fn is_noise_line(trimmed: &str) -> bool {
    trimmed.starts_with("Attention: Next.js now collects")
        || trimmed.starts_with("Learn more:")
        || trimmed.starts_with("▲ Next.js")
        || trimmed.starts_with("Creating an optimized production build")
        || trimmed.starts_with("")
}

fn trim_blank_edges(lines: Vec<String>) -> Vec<String> {
    let start = lines
        .iter()
        .position(|line| !line.trim().is_empty())
        .unwrap_or(lines.len());
    let end = lines
        .iter()
        .rposition(|line| !line.trim().is_empty())
        .map_or(start, |index| index + 1);
    lines[start..end].to_vec()
}

fn finish(input: &str) -> String {
    let deduped = dedup_consecutive(input);
    cap_lines(
        &middle_truncate(&deduped, 64 * 1024, 32 * 1024, 32 * 1024),
        MAX_LINES,
    )
}

fn cap_lines(input: &str, max_lines: usize) -> String {
    let lines: Vec<&str> = input.lines().collect();
    if lines.len() <= max_lines {
        return input.trim_end().to_string();
    }
    let mut kept = lines
        .iter()
        .take(max_lines)
        .copied()
        .collect::<Vec<_>>()
        .join("\n");
    kept.push_str(&format!(
        "\n... truncated {} lines",
        lines.len() - max_lines
    ));
    kept
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_success_output() -> &'static str {
        r#"Attention: Next.js now collects completely anonymous telemetry...

▲ Next.js 15.2.0

   Creating an optimized production build ...
 ✓ Compiled successfully
 ✓ Linting and checking validity of types
 ✓ Collecting page data
 ✓ Generating static pages (12/12)
 ✓ Collecting build traces
 ✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                    142 B          85.5 kB
├ ○ /_not-found                          885 B          82.5 kB
├ ○ /about                               142 B          85.5 kB
├ ƒ /api/health                          0 B                0 B
└ ƒ /dashboard                           1.2 kB         92.0 kB
+ First Load JS shared by all                            82.4 kB
  ├ chunks/2117-abc.js                   29.6 kB
  └ other shared chunks (total)          52.7 kB

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
"#
    }

    #[test]
    fn matches_next_build_token_anywhere_and_rejects_substrings() {
        let compressor = NextCompressor;
        assert!(compressor.matches("next build"));
        assert!(compressor.matches("npx next build"));
        assert!(compressor.matches("pnpm exec next build"));
        assert!(compressor.matches("bun run next build"));
        assert!(compressor.matches("./node_modules/.bin/next build"));
        assert!(!compressor.matches("next dev"));
        assert!(!compressor.matches("next-i18n-router build"));
        assert!(!compressor.matches("pingnext build"));
    }

    #[test]
    fn happy_path_drops_noise_and_preserves_route_table() {
        let compressed = compress_next(sample_success_output());

        assert!(compressed.starts_with("Route (app)"));
        assert!(compressed.contains("└ ƒ /dashboard"));
        assert!(compressed.contains("+ First Load JS shared by all"));
        assert!(compressed.contains("○  (Static)"));
        assert!(compressed.contains("ƒ  (Dynamic)"));
        assert!(!compressed.contains("telemetry"));
        assert!(!compressed.contains("Next.js 15.2.0"));
        assert!(!compressed.contains("Compiled successfully"));
    }

    #[test]
    fn failure_path_preserves_error_block_verbatim() {
        let error_block = r#"Failed to compile.

./app/page.tsx:10:7
Type error: Type 'string' is not assignable to type 'number'.

   8 | export default function Page() {
   9 |   const count: number = 1;
> 10 |   const bad: number = "oops";
     |       ^
  11 |   return <main>{bad}</main>;
"#;
        let output = format!(
            "Attention: Next.js now collects completely anonymous telemetry...\n▲ Next.js 15.2.0\n ✓ Compiled successfully\n{error_block}"
        );

        let compressed = compress_next(&output);

        assert_eq!(compressed, error_block.trim_end());
        assert!(compressed.contains("Type error:"));
        assert!(!compressed.contains("Route (app)"));
    }

    #[test]
    fn large_route_table_compresses_below_half_and_keeps_markers() {
        let mut output = String::from(
            "Attention: Next.js now collects completely anonymous telemetry...\n▲ Next.js 15.2.0\n   Creating an optimized production build ...\n",
        );
        for index in 0..400 {
            output.push_str(&format!(" ✓ Progress step {index}\n"));
        }
        output.push_str("\nRoute (app)                              Size     First Load JS\n");
        output.push_str("┌ ○ /                                    142 B          85.5 kB\n");
        for index in 0..120 {
            output.push_str(&format!("├ ○ /page-{index:<30} 142 B          85.5 kB\n"));
        }
        output.push_str("└ ƒ /dashboard                           1.2 kB         92.0 kB\n");
        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");

        let compressed = compress_next(&output);

        assert!(compressed.len() < output.len() / 2);
        assert!(compressed.contains("Route (app)"));
        assert!(compressed.contains("/dashboard"));
        assert!(compressed.contains("ƒ  (Dynamic)"));
        assert!(!compressed.contains("Progress step"));
    }

    #[test]
    fn syntax_error_preserves_error_instead_of_extracting_table() {
        let output = r#"▲ Next.js 15.2.0
SyntaxError: Unexpected token '<'
    at app/page.tsx:1:1

Route (app)                              Size     First Load JS
┌ ○ /                                    142 B          85.5 kB
"#;

        let compressed = compress_next(output);

        assert!(compressed.starts_with("SyntaxError: Unexpected token '<'"));
        assert!(compressed.contains("at app/page.tsx:1:1"));
        assert!(compressed.contains("Route (app)"));
    }
}