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, DiffLine, DiffLineKind};

/// Loads and classifies the diff for one commit and repository-relative path.
#[must_use = "the loaded diff or error must be handled"]
pub(crate) fn load_diff(
    repo_root: &Path,
    commit_hash: &str,
    repo_path: &Path,
) -> Result<Vec<DiffLine>> {
    // load_diff is intentionally limited to hashes emitted by load_commits.
    validate_hex_hash(commit_hash)?;

    let output = run_git_pathspec(
        repo_root,
        "git show",
        &[
            "show",
            "--format=",
            "--color=never",
            "--no-ext-diff",
            commit_hash,
        ],
        repo_path,
    )?;

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

fn parse_diff(output: &str) -> Vec<DiffLine> {
    let mut lines: Vec<DiffLine> = output
        .lines()
        .map(|line| DiffLine {
            text: line.to_string(),
            kind: classify_diff_line(line),
        })
        .collect();

    if lines.is_empty() {
        lines.push(DiffLine {
            text: "(empty diff)".to_string(),
            kind: DiffLineKind::Context,
        });
    }

    lines
}

fn classify_diff_line(line: &str) -> DiffLineKind {
    if line.starts_with("@@") {
        DiffLineKind::Hunk
    } else if is_diff_metadata(line) {
        DiffLineKind::Metadata
    } else if line.starts_with('+') {
        DiffLineKind::Add
    } else if line.starts_with('-') {
        DiffLineKind::Remove
    } else {
        DiffLineKind::Context
    }
}

fn is_diff_metadata(line: &str) -> bool {
    line.starts_with("diff --git")
        || line.starts_with("index ")
        || line.starts_with("--- a/")
        || line.starts_with("--- \"a/")
        || line == "--- /dev/null"
        || line.starts_with("+++ b/")
        || line.starts_with("+++ \"b/")
        || line == "+++ /dev/null"
        || line.starts_with("new file mode ")
        || line.starts_with("deleted file mode ")
        || line.starts_with("old mode ")
        || line.starts_with("new mode ")
        || line.starts_with("similarity index ")
        || line.starts_with("dissimilarity index ")
        || line.starts_with("rename from ")
        || line.starts_with("rename to ")
        || line.starts_with("copy from ")
        || line.starts_with("copy to ")
        || line.starts_with("Binary files ")
}

fn validate_hex_hash(commit_hash: &str) -> Result<()> {
    let is_supported_len = matches!(commit_hash.len(), 40 | 64);

    if is_supported_len && commit_hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
        Ok(())
    } else {
        Err(AppError::message(format!(
            "invalid commit hash: {commit_hash}"
        )))
    }
}

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

    #[test]
    fn classifies_diff_lines() {
        assert_eq!(classify_diff_line("+added"), DiffLineKind::Add);
        assert_eq!(classify_diff_line("-removed"), DiffLineKind::Remove);
        assert_eq!(classify_diff_line("@@ -1 +1 @@"), DiffLineKind::Hunk);
        assert_eq!(classify_diff_line("+++ b/file.rs"), DiffLineKind::Metadata);
        assert_eq!(classify_diff_line("--- a/file.rs"), DiffLineKind::Metadata);
        assert_eq!(
            classify_diff_line("+++ \"b/file with spaces.rs\""),
            DiffLineKind::Metadata
        );
        assert_eq!(
            classify_diff_line("--- \"a/file with spaces.rs\""),
            DiffLineKind::Metadata
        );
        assert_eq!(classify_diff_line("++++content"), DiffLineKind::Add);
        assert_eq!(classify_diff_line("----content"), DiffLineKind::Remove);
        assert_eq!(
            classify_diff_line("index abc..def 100644"),
            DiffLineKind::Metadata
        );
        assert_eq!(classify_diff_line(" context"), DiffLineKind::Context);
    }

    #[test]
    fn parse_diff_adds_empty_placeholder() {
        let lines = parse_diff("");
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].text, "(empty diff)");
    }

    #[test]
    fn parse_diff_classifies_realistic_mixed_content() {
        let lines = parse_diff(
            "diff --git a/file.txt b/file.txt\n\
index abc..def 100644\n\
--- a/file.txt\n\
+++ b/file.txt\n\
@@ -1,2 +1,2 @@\n\
 context\n\
-old\n\
+new\n\
----removed content starts with dashes\n\
+++added content starts with pluses\n",
        );

        assert_eq!(lines[0].kind, DiffLineKind::Metadata);
        assert_eq!(lines[4].kind, DiffLineKind::Hunk);
        assert_eq!(lines[5].kind, DiffLineKind::Context);
        assert_eq!(lines[6].kind, DiffLineKind::Remove);
        assert_eq!(lines[7].kind, DiffLineKind::Add);
        assert_eq!(lines[8].kind, DiffLineKind::Remove);
        assert_eq!(lines[9].kind, DiffLineKind::Add);
    }

    #[test]
    fn validates_supported_commit_hash_formats() {
        assert!(validate_hex_hash("0123456789abcdef0123456789abcdef01234567").is_ok());
        assert!(validate_hex_hash(
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        )
        .is_ok());
        assert!(validate_hex_hash("abc1234").is_err());
        assert!(validate_hex_hash("abc123").is_err());
        assert!(validate_hex_hash("0123456789abcdef0123456789abcdef012345678").is_err());
        assert!(validate_hex_hash("--exec=rm").is_err());
        assert!(validate_hex_hash("HEAD").is_err());
        assert!(validate_hex_hash("").is_err());
    }
}