agent-file-tools 0.36.0

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use crate::compress::caps::{cap_classified_blocks, ClassifiedBlock, DropClass};
use crate::compress::generic::GenericCompressor;
use crate::compress::{CompressionResult, Compressor};

pub struct CargoCompressor;

impl Compressor for CargoCompressor {
    fn matches(&self, command: &str) -> bool {
        command
            .split_whitespace()
            .next()
            .is_some_and(|head| head == "cargo")
    }

    fn compress(&self, command: &str, output: &str) -> CompressionResult {
        match cargo_subcommand(command).as_deref() {
            Some("build" | "check" | "clippy") => compress_build_like(output),
            Some("test") => compress_test(output),
            _ => GenericCompressor::compress_output(output).into(),
        }
    }

    fn matches_output(&self, output: &str) -> bool {
        output.lines().any(is_cargo_test_signature_line)
    }

    fn compress_output_match(&self, output: &str) -> CompressionResult {
        compress_test(output)
    }
}

fn is_cargo_test_signature_line(line: &str) -> bool {
    line.starts_with("test result:")
        || line.starts_with("failures:")
        || (line.starts_with("---- ") && line.ends_with(" stdout ----"))
}

fn cargo_subcommand(command: &str) -> Option<String> {
    let mut seen_cargo = false;
    for token in command.split_whitespace() {
        if !seen_cargo {
            if token == "cargo" {
                seen_cargo = true;
            }
            continue;
        }
        if token.starts_with('-') {
            continue;
        }
        return Some(token.to_string());
    }
    None
}

fn compress_build_like(output: &str) -> CompressionResult {
    let lines: Vec<&str> = output.lines().collect();
    let has_diagnostic = lines
        .iter()
        .any(|line| is_warning_or_error(line) || line.trim_start().starts_with("error["));

    if !has_diagnostic {
        return CompressionResult::new(output.trim_end().to_string());
    }

    let mut blocks = Vec::new();
    let mut index = 0usize;

    while index < lines.len() {
        let line = lines[index];
        if is_ignored_progress(line) {
            index += 1;
            continue;
        }

        if is_warning_or_error(line) || line.trim_start().starts_with("error[") {
            let class = if line.trim_start().starts_with("warning:") {
                DropClass::Warning
            } else {
                DropClass::Error
            };
            let start = index;
            index += 1;
            while index < lines.len() && !starts_next_build_message(lines[index]) {
                index += 1;
            }
            blocks.push(ClassifiedBlock::new(class, lines[start..index].join("\n")));
            continue;
        }

        if is_final_cargo_summary(line) {
            blocks.push(ClassifiedBlock::unclassified(line.to_string()));
        }
        index += 1;
    }

    let capped = cap_classified_blocks(blocks);
    CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
}

fn starts_next_build_message(line: &str) -> bool {
    is_ignored_progress(line)
        || is_warning_or_error(line)
        || line.trim_start().starts_with("error[")
        || is_final_cargo_summary(line)
}

fn is_warning_or_error(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed.starts_with("warning:") || trimmed.starts_with("error:")
}

fn is_ignored_progress(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed == "Updating crates.io index" || is_compiling_line(trimmed)
}

fn is_compiling_line(trimmed: &str) -> bool {
    let Some(rest) = trimmed.strip_prefix("Compiling ") else {
        return false;
    };
    let mut parts = rest.split_whitespace();
    let _crate_name = parts.next();
    parts.next().is_some_and(|part| {
        part.strip_prefix('v').is_some_and(|version| {
            version
                .chars()
                .all(|char| char.is_ascii_digit() || char == '.')
        })
    })
}

fn is_final_cargo_summary(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed.starts_with("Finished ")
        || trimmed.starts_with("error: could not compile")
        || trimmed.starts_with("test result:")
}

fn compress_test(output: &str) -> CompressionResult {
    let lines: Vec<&str> = output.lines().collect();
    let has_failures = lines.iter().any(|line| line.trim() == "failures:");
    if !has_failures {
        let result: Vec<String> = lines
            .iter()
            .filter(|line| {
                let trimmed = line.trim_start();
                trimmed.starts_with("running ")
                    || trimmed.starts_with("test result:")
                    || is_final_cargo_summary(trimmed)
            })
            .map(|line| (*line).to_string())
            .collect();
        return CompressionResult::new(trim_trailing_lines(&result.join("\n")));
    }

    let mut blocks = Vec::new();
    let mut index = 0usize;
    while index < lines.len() {
        let line = lines[index];
        let trimmed = line.trim_start();
        if trimmed.starts_with("running ") || trimmed.starts_with("test result:") {
            blocks.push(ClassifiedBlock::unclassified(line.to_string()));
            index += 1;
            continue;
        }

        if trimmed == "failures:" {
            let start = index;
            let mut next = index + 1;
            while next < lines.len() && lines[next].trim().is_empty() {
                next += 1;
            }
            if next < lines.len() && lines[next].starts_with("---- ") {
                blocks.push(ClassifiedBlock::unclassified(line.to_string()));
                index += 1;
                continue;
            }

            index += 1;
            while index < lines.len() && !lines[index].trim_start().starts_with("test result:") {
                index += 1;
            }
            blocks.push(ClassifiedBlock::unclassified(
                lines[start..index].join("\n"),
            ));
            continue;
        }

        if line.starts_with("---- ") {
            let start = index;
            while index < lines.len() {
                index += 1;
                if index < lines.len()
                    && (lines[index].starts_with("---- ")
                        || lines[index].trim_start().starts_with("test result:")
                        || lines[index].trim() == "failures:")
                {
                    break;
                }
            }
            blocks.push(ClassifiedBlock::new(
                DropClass::Failure,
                lines[start..index].join("\n"),
            ));
            continue;
        }

        index += 1;
    }

    let capped = cap_classified_blocks(blocks);
    CompressionResult::with_class_drops(trim_trailing_lines(&capped.text), capped.dropped_by_class)
}

fn trim_trailing_lines(input: &str) -> String {
    input
        .lines()
        .map(str::trim_end)
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compress::caps::{DropClass, CAP_ERRORS};

    #[test]
    fn cargo_test_caps_failure_blocks_after_failures_header() {
        let mut output = String::from("running 40 tests\n\nfailures:\n\n");
        for index in 0..40 {
            output.push_str(&format!(
                "---- case_{index} stdout ----\nthread 'case_{index}' panicked at src/lib.rs:{index}:1\nstack line {index}\n\n"
            ));
        }
        output.push_str("failures:\n");
        for index in 0..40 {
            output.push_str(&format!("    case_{index}\n"));
        }
        output.push_str(
            "\ntest result: FAILED. 0 passed; 40 failed; 0 ignored; 0 measured; 0 filtered out\n",
        );

        let result = compress_test(&output);

        assert_eq!(
            result.dropped_by_class.get(&DropClass::Failure),
            Some(&(40 - CAP_ERRORS))
        );
        assert_eq!(result.text.matches(" stdout ----").count(), CAP_ERRORS);
        assert!(result.text.contains("---- case_19 stdout ----"));
        assert!(!result.text.contains("---- case_20 stdout ----"));
        assert!(result.had_inner_drop);
        assert!(!result.offset_hint_eligible);
    }
}