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!(
    LEVEL_RE,
    r"(?i)\b(ERROR|WARN(?:ING)?|FATAL|DEBUG|INFO|TRACE)\b"
);

fn classify(line: &str) -> Option<&'static str> {
    let caps = LEVEL_RE.captures(line)?;
    match caps[1].to_ascii_uppercase().as_str() {
        "ERROR" => Some("ERROR"),
        "FATAL" => Some("FATAL"),
        "WARN" | "WARNING" => Some("WARN"),
        _ => None,
    }
}

pub fn parse(output: &str) -> ParsedResult {
    let lines: Vec<&str> = output.lines().collect();
    let mut failures: Vec<Diagnostic> = Vec::new();
    let mut warnings: Vec<Diagnostic> = Vec::new();
    let mut emitted: Vec<String> = Vec::new();

    let mut i = 0;
    let mut first_interesting = true;
    while i < lines.len() {
        let line = lines[i];
        let Some(level) = classify(line) else {
            i += 1;
            continue;
        };

        if first_interesting && i > 0 {
            let prev = lines[i - 1];
            if !prev.trim().is_empty() && classify(prev).is_none() {
                emitted.push(format!("context: {prev}"));
            }
            first_interesting = false;
        }

        let mut count = 1;
        while i + count < lines.len() && lines[i + count] == line {
            count += 1;
        }

        let display = if count > 1 {
            format!("{line} [repeated {count} times]")
        } else {
            line.to_string()
        };
        emitted.push(display.clone());

        let diag = Diagnostic {
            name: format!("log/{level}"),
            location: None,
            message: display,
        };
        if level == "WARN" {
            warnings.push(diag);
        } else {
            failures.push(diag);
        }

        i += count;
    }

    let summary = format!(
        "{} error(s)/fatal(s), {} warning(s)",
        failures.len(),
        warnings.len()
    );

    let tail = if emitted.is_empty() {
        None
    } else {
        Some(emitted.join("\n"))
    };

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

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

    #[test]
    fn parse_dedupe_and_context() {
        let output = "\
2026-05-26 10:00:00 INFO Starting server on port 8080
2026-05-26 10:00:01 INFO Connected to database
2026-05-26 10:00:02 ERROR Connection refused: redis:6379
2026-05-26 10:00:02 ERROR Connection refused: redis:6379
2026-05-26 10:00:02 ERROR Connection refused: redis:6379
2026-05-26 10:00:05 WARN Retrying in 5 seconds
2026-05-26 10:00:10 INFO Reconnected to redis
";
        let result = parse(output);
        let tail = result.tail.expect("tail");
        assert!(tail.contains("context: 2026-05-26 10:00:01 INFO Connected to database"));
        assert!(tail.contains("Connection refused: redis:6379 [repeated 3 times]"));
        assert!(tail.contains("Retrying in 5 seconds"));
        assert!(!tail.contains("Reconnected to redis"));
        assert_eq!(result.failures.len(), 1);
        assert_eq!(result.warnings.len(), 1);
    }

    #[test]
    fn parse_no_problems() {
        let output = "\
2026-05-26 10:00:00 INFO Starting
2026-05-26 10:00:01 INFO Connected
2026-05-26 10:00:02 DEBUG initialization done
";
        let result = parse(output);
        assert_eq!(result.failures.len(), 0);
        assert_eq!(result.warnings.len(), 0);
        assert!(result.tail.is_none());
    }

    #[test]
    fn parse_fatal_treated_as_failure() {
        let output = "FATAL out of memory\n";
        let result = parse(output);
        assert_eq!(result.failures.len(), 1);
        assert!(result.failures[0].name.contains("FATAL"));
    }
}