prx 0.5.9

Praxis — agent-native Unix tools. Single binary replacing grep, cat, find, sed, diff for AI coding agents.
use super::{Diagnostic, ParsedResult, define_regex};

define_regex!(STEP_RE, r"^Step (\d+/\d+) : (.+)$");
define_regex!(SUCCESS_BUILT_RE, r"^Successfully built (\S+)");
define_regex!(SUCCESS_TAGGED_RE, r"^Successfully tagged (\S+)");
define_regex!(ERROR_RE, r"^(ERROR|error):\s*(.+)$");
define_regex!(NONZERO_RE, r"returned a non-zero code:\s*(\d+)");

fn is_noise(line: &str) -> bool {
    let trimmed = line.trim_start();
    line.starts_with("Sending build context")
        || trimmed.starts_with("---> ")
        || trimmed == "--->"
        || line.contains(": Pulling from")
        || line.contains(": Already exists")
        || line.contains(": Pull complete")
        || line.contains(": Downloading")
        || line.contains(": Extracting")
        || line.contains(": Waiting")
        || line.contains(": Verifying Checksum")
        || line.contains(": Download complete")
}

pub fn parse(output: &str) -> ParsedResult {
    let lines: Vec<&str> = output.lines().collect();
    let mut failures = Vec::new();
    let mut last_step: Option<String> = None;
    let mut built_image: Option<String> = None;
    let mut tagged_image: Option<String> = None;
    let mut had_error = false;

    for line in &lines {
        if let Some(caps) = STEP_RE.captures(line) {
            last_step = Some(format!("Step {}: {}", &caps[1], &caps[2]));
        }
        if let Some(caps) = SUCCESS_BUILT_RE.captures(line) {
            built_image = Some(caps[1].to_string());
        }
        if let Some(caps) = SUCCESS_TAGGED_RE.captures(line) {
            tagged_image = Some(caps[1].to_string());
        }
        if let Some(caps) = ERROR_RE.captures(line) {
            had_error = true;
            failures.push(Diagnostic {
                name: "docker/error".to_string(),
                location: last_step.clone(),
                message: caps[2].to_string(),
            });
        }
        if let Some(caps) = NONZERO_RE.captures(line) {
            had_error = true;
            failures.push(Diagnostic {
                name: format!("docker/exit_{}", &caps[1]),
                location: last_step.clone(),
                message: line.trim().to_string(),
            });
        }
    }

    let (summary, tail) = if had_error {
        let filtered: Vec<&&str> = lines.iter().filter(|l| !is_noise(l)).collect();
        let start = filtered.len().saturating_sub(20);
        let tail_text: String = filtered[start..]
            .iter()
            .map(|s| **s)
            .collect::<Vec<&str>>()
            .join("\n");
        let summary = match &last_step {
            Some(s) => format!("build FAILED at {s}"),
            None => "build FAILED".to_string(),
        };
        (summary, Some(tail_text))
    } else if built_image.is_some() || tagged_image.is_some() {
        let parts: Vec<String> = [
            built_image.as_ref().map(|i| format!("built {i}")),
            tagged_image.as_ref().map(|i| format!("tagged {i}")),
        ]
        .into_iter()
        .flatten()
        .collect();
        (format!("build succeeded ({})", parts.join(", ")), None)
    } else {
        ("build complete".to_string(), None)
    };

    ParsedResult {
        summary,
        passed: 0,
        failed: failures.len(),
        skipped: 0,
        failures,
        warnings: vec![],
        tail,
    }
}

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

    #[test]
    fn parse_docker_build_failure() {
        let output = "\
Sending build context to Docker daemon  2.048kB
Step 1/5 : FROM python:3.11
 ---> abc123
Step 2/5 : COPY requirements.txt .
 ---> Using cache
Step 3/5 : RUN pip install -r requirements.txt
 ---> Running in def456
ERROR: Could not find a version that satisfies the requirement nonexistent-pkg
The command '/bin/sh -c pip install -r requirements.txt' returned a non-zero code: 1
";
        let result = parse(output);
        assert!(result.failed >= 1);
        assert!(result.summary.contains("FAILED"));
        let tail = result.tail.expect("tail on failure");
        assert!(!tail.contains("Sending build context"));
        assert!(tail.contains("ERROR"));
    }

    #[test]
    fn parse_docker_build_success() {
        let output = "\
Successfully built abc123def456
Successfully tagged myapp:latest
";
        let result = parse(output);
        assert_eq!(result.failed, 0);
        assert!(result.summary.contains("succeeded"));
        assert!(result.summary.contains("myapp:latest"));
        assert!(result.tail.is_none());
    }

    #[test]
    fn parse_docker_build_strips_noise() {
        let output = "\
Sending build context to Docker daemon  10kB
Step 1/2 : FROM alpine
 ---> aaa
Step 2/2 : RUN false
ERROR: build failed
";
        let result = parse(output);
        let tail = result.tail.unwrap();
        assert!(!tail.contains("Sending build context"));
        assert!(!tail.contains(" ---> aaa"));
    }
}