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!(
    SUMMARY_RE,
    r"test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored"
);
define_regex!(FAILURE_HEADER_RE, r"^---- (.+) stdout ----$");
define_regex!(PANIC_RE, r"panicked at (.+):(\d+)");

pub fn parse(output: &str) -> ParsedResult {
    let mut passed = 0;
    let mut failed = 0;
    let mut skipped = 0;
    let mut summary = String::new();
    let mut failures = Vec::new();

    if let Some(caps) = SUMMARY_RE.captures(output) {
        passed = caps[2].parse().unwrap_or(0);
        failed = caps[3].parse().unwrap_or(0);
        skipped = caps[4].parse().unwrap_or(0);
        summary = format!("{passed} passed, {failed} failed, {skipped} ignored");
    }

    let lines: Vec<&str> = output.lines().collect();
    let mut i = 0;
    while i < lines.len() {
        if let Some(caps) = FAILURE_HEADER_RE.captures(lines[i]) {
            let test_name = caps[1].to_string();
            let mut location = None;
            let mut message_lines = Vec::new();

            i += 1;
            while i < lines.len()
                && !lines[i].starts_with("----")
                && !lines[i].starts_with("failures:")
            {
                if let Some(panic_caps) = PANIC_RE.captures(lines[i]) {
                    location = Some(format!("{}:{}", &panic_caps[1], &panic_caps[2]));
                } else if !lines[i].starts_with("note:") && !lines[i].starts_with("thread '") {
                    let trimmed = lines[i].trim();
                    if !trimmed.is_empty() {
                        message_lines.push(trimmed.to_string());
                    }
                }
                i += 1;
            }

            failures.push(Diagnostic {
                name: test_name,
                location,
                message: message_lines.join("\n"),
            });
            continue;
        }
        i += 1;
    }

    if summary.is_empty() {
        summary = format!("{passed} passed, {failed} failed");
    }

    ParsedResult::new(summary, passed, failed, skipped, failures)
}

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

    #[test]
    fn parse_all_passing() {
        let output = "\
running 3 tests
test hash::tests::deterministic ... ok
test hash::tests::empty_input ... ok
test hash::tests::hex_length ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
";
        let result = parse(output);
        assert_eq!(result.passed, 3);
        assert_eq!(result.failed, 0);
        assert!(result.failures.is_empty());
        assert!(result.summary.contains("3 passed"));
    }

    #[test]
    fn parse_with_failure() {
        let output = "\
running 2 tests
test hash::tests::deterministic ... ok
test hash::tests::bad_test ... FAILED

failures:

---- hash::tests::bad_test stdout ----
thread 'hash::tests::bad_test' panicked at src/hash.rs:42:9:
assertion `left == right` failed
  left: 1
 right: 2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    hash::tests::bad_test

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
";
        let result = parse(output);
        assert_eq!(result.passed, 1);
        assert_eq!(result.failed, 1);
        assert_eq!(result.failures.len(), 1);
        assert_eq!(result.failures[0].name, "hash::tests::bad_test");
        assert!(
            result.failures[0]
                .location
                .as_ref()
                .unwrap()
                .contains("src/hash.rs:42")
        );
        assert!(result.failures[0].message.contains("left == right"));
    }

    #[test]
    fn parse_with_ignored() {
        let output = "test result: ok. 5 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 0.00s\n";
        let result = parse(output);
        assert_eq!(result.passed, 5);
        assert_eq!(result.skipped, 2);
    }
}