git-file-history 0.1.0

TUI for browsing the Git history of a single file
use std::path::Path;

use crate::error::{AppError, Result};

use super::{run_git_pathspec, Commit};

/// Loads the commits that touched a repository-relative path.
#[must_use = "the loaded commit history or error must be handled"]
pub(crate) fn load_commits(repo_root: &Path, repo_path: &Path) -> Result<Vec<Commit>> {
    let output = run_git_pathspec(
        repo_root,
        "git log",
        &["log", "--pretty=format:%H%x00%s%x00%cn, %ah%x00"],
        repo_path,
    )?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_commit_log(&stdout)
}

fn parse_commit_log(output: &str) -> Result<Vec<Commit>> {
    let mut commits = Vec::new();
    let mut fields = output.split('\0');

    while let Some(hash) = fields.next() {
        let hash = hash.trim_start_matches('\n');
        if hash.is_empty() {
            if fields.any(|field| !field.is_empty()) {
                return Err(AppError::message("unexpected trailing git log field"));
            }
            break;
        }
        let Some(subject) = fields.next() else {
            return Err(AppError::message(format!(
                "unexpected git log record for commit {hash}"
            )));
        };
        let Some(description) = fields.next() else {
            return Err(AppError::message(format!(
                "unexpected git log record for commit {hash}"
            )));
        };

        commits.push(Commit {
            hash: hash.to_string(),
            subject: subject.to_string(),
            description: description.to_string(),
        });
    }

    Ok(commits)
}

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

    #[test]
    fn parses_commit_log_with_nul_separated_fields() {
        let output = "abc123\0subject with \x1e marker\0Name, 2 days ago\0";
        let commits = parse_commit_log(output).expect("commit log should parse");

        assert_eq!(commits.len(), 1);
        assert_eq!(commits[0].hash, "abc123");
        assert_eq!(commits[0].subject, "subject with \x1e marker");
        assert_eq!(commits[0].description, "Name, 2 days ago");
    }

    #[test]
    fn parses_multiple_commit_log_records_without_newline_in_hash() {
        let output = "abc123\0first\0Name, 2 days ago\0\ndef456\0second\0Name, 1 day ago\0";
        let commits = parse_commit_log(output).expect("commit log should parse");

        assert_eq!(commits.len(), 2);
        assert_eq!(commits[0].hash, "abc123");
        assert_eq!(commits[1].hash, "def456");
    }

    #[test]
    fn rejects_malformed_commit_log_record() {
        let error = parse_commit_log("abc123 subject only").expect_err("line should fail");
        assert!(error.to_string().contains("unexpected git log record"));
    }

    #[test]
    fn rejects_trailing_commit_log_fields() {
        let error = parse_commit_log("abc123\0subject\0Name, 1 day ago\0\0junk")
            .expect_err("trailing field should fail");
        assert!(error
            .to_string()
            .contains("unexpected trailing git log field"));
    }
}