prx 0.5.9

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

define_regex!(COMMIT_RE, r"^commit ([0-9a-f]{7,40})");
define_regex!(AUTHOR_RE, r"^Author:\s+(.+?)\s+<");

pub fn parse(output: &str) -> ParsedResult {
    let mut commits: Vec<String> = Vec::new();
    let mut current_hash: Option<String> = None;
    let mut current_author: Option<String> = None;
    let mut current_subject: Option<String> = None;

    let flush = |hash: &mut Option<String>,
                 author: &mut Option<String>,
                 subject: &mut Option<String>,
                 commits: &mut Vec<String>| {
        if let (Some(h), Some(s)) = (hash.as_ref(), subject.as_ref()) {
            let a = author.as_deref().unwrap_or("?");
            commits.push(format!("{h} [{a}] {s}"));
        }
        *hash = None;
        *author = None;
        *subject = None;
    };

    for line in output.lines() {
        if let Some(caps) = COMMIT_RE.captures(line) {
            flush(
                &mut current_hash,
                &mut current_author,
                &mut current_subject,
                &mut commits,
            );
            let full = &caps[1];
            current_hash = Some(full[..full.len().min(7)].to_string());
            continue;
        }

        if let Some(caps) = AUTHOR_RE.captures(line) {
            current_author = Some(caps[1].to_string());
            continue;
        }

        if current_hash.is_some() && current_subject.is_none() {
            let trimmed = line.trim();
            if !trimmed.is_empty()
                && !line.starts_with("Date:")
                && !line.starts_with("Merge:")
                && !line.starts_with("Author:")
            {
                current_subject = Some(trimmed.to_string());
            }
        }
    }

    flush(
        &mut current_hash,
        &mut current_author,
        &mut current_subject,
        &mut commits,
    );

    let summary = format!("{} commit(s)", commits.len());
    let tail = if commits.is_empty() {
        None
    } else {
        Some(commits.join("\n"))
    };

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

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

    #[test]
    fn parse_git_log_multiple_commits() {
        let output = "\
commit abc1234567890def
Author: Alice <alice@example.com>
Date:   Mon May 26 10:00:00 2026 +0000

    Fix authentication bug

commit def4567890123abc
Author: Bob <bob@example.com>
Date:   Sun May 25 09:00:00 2026 +0000

    Add login endpoint
";
        let result = parse(output);
        let tail = result.tail.expect("tail present");
        assert_eq!(tail.lines().count(), 2);
        assert!(tail.contains("abc1234"));
        assert!(tail.contains("[Alice]"));
        assert!(tail.contains("Fix authentication bug"));
        assert!(tail.contains("def4567"));
        assert!(tail.contains("[Bob]"));
        assert!(result.summary.contains("2 commit"));
    }

    #[test]
    fn parse_git_log_empty() {
        let result = parse("");
        assert!(result.tail.is_none());
        assert!(result.summary.contains("0 commit"));
    }

    #[test]
    fn parse_git_log_truncates_long_hash() {
        let output = "\
commit 0123456789abcdef0123456789abcdef01234567
Author: Carol <carol@example.com>
Date:   Mon May 26 10:00:00 2026 +0000

    A subject
";
        let result = parse(output);
        let tail = result.tail.expect("tail");
        assert!(tail.starts_with("0123456 "));
    }
}