git-file-history 0.1.0

TUI for browsing the Git history of a single file
use std::{
    path::Path,
    process::{Command, Output},
};

use crate::error::AppError;

mod diff;
mod history;
mod target;

pub(crate) use diff::load_diff;
pub(crate) use history::load_commits;
pub(crate) use target::resolve_target_file;

/// A commit that touched the target file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Commit {
    /// The full commit hash. SHA-1 repositories produce 40 characters; SHA-256 repositories produce 64.
    pub(crate) hash: String,
    /// The commit subject line.
    pub(crate) subject: String,
    /// The committer name and relative commit date shown in the history list.
    pub(crate) description: String,
}

/// A rendered line from a commit diff.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiffLine {
    /// The raw diff line text with no terminal styling applied.
    pub(crate) text: String,
    /// The classified role of the diff line.
    pub(crate) kind: DiffLineKind,
}

/// The visual category used to style a diff line.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DiffLineKind {
    /// An added line.
    Add,
    /// A removed line.
    Remove,
    /// A hunk header line.
    Hunk,
    /// Git diff metadata, such as file headers and mode changes.
    Metadata,
    /// Unchanged context or placeholder text.
    Context,
}

fn format_command_failure(command_name: &'static str, output: &Output) -> AppError {
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stderr = stderr.trim();
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stdout = stdout.trim();

    if !stderr.is_empty() {
        AppError::git_command_failed(command_name, output.status, stderr.to_string())
    } else if !stdout.is_empty() {
        AppError::git_command_failed(command_name, output.status, stdout.to_string())
    } else {
        AppError::git_command_failed_without_output(command_name, output.status)
    }
}

fn run_git(
    current_dir: &Path,
    command_name: &'static str,
    args: &[&str],
) -> crate::error::Result<Output> {
    let output = Command::new("git")
        .current_dir(current_dir)
        .args(args)
        .output()
        .map_err(AppError::git_spawn)?;

    if !output.status.success() {
        return Err(format_command_failure(command_name, &output));
    }

    Ok(output)
}

fn run_git_pathspec(
    current_dir: &Path,
    command_name: &'static str,
    args: &[&str],
    repo_path: &Path,
) -> crate::error::Result<Output> {
    let output = Command::new("git")
        .current_dir(current_dir)
        .env("GIT_LITERAL_PATHSPECS", "1")
        .args(args)
        .arg("--")
        .arg(repo_path)
        .output()
        .map_err(AppError::git_spawn)?;

    if !output.status.success() {
        return Err(format_command_failure(command_name, &output));
    }

    Ok(output)
}