rustik-repo 0.2.0

Repository abstraction layer for Rustik tools.
Documentation
//! Repository abstraction layer for Rustik tools.
//!
//! Expanding hunk context means rerunning a diff at a different unified-context
//! width. The [`Repo`] trait keeps that repository access behind a small
//! boundary so diff viewers stay I/O-free and testable; [`GitRepo`] is the
//! `git diff` implementation used by the `rustik-diff` pager. The trait yields
//! raw unified-diff bytes — parsing is left to the caller, so this crate
//! carries no diff-format dependency.
#![deny(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::unwrap_used)]

use std::ffi::{OsStr, OsString};
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;

// Constants //

/// Environment variable that disables Git's colored output.
const NO_COLOR: &str = "NO_COLOR";
/// Pathspec separator that ends Git's option list.
const PATHSPEC_SEPARATOR: &str = "--";

// Repo //

/// A diff backend a viewer can re-query for wider hunk context.
pub trait Repo {
    /// Refetches `paths` with `context_lines` of unified context per hunk.
    ///
    /// Returns the raw unified-diff bytes; decoding them is the caller's job.
    fn diff(&self, context_lines: usize, paths: &[&Path]) -> Result<Vec<u8>, RepoError>;
}

/// A [`Repo`] backed by the `git diff` command.
pub struct GitRepo {
    /// Working directory for `git diff` invocations.
    cwd: PathBuf,
    /// CLI arguments forwarded to Git, as the pager was invoked.
    args: Vec<OsString>,
}

impl GitRepo {
    /// Creates a repo that runs `git diff` in `cwd` with forwarded `args`.
    pub fn new(cwd: impl Into<PathBuf>, args: impl Into<Vec<OsString>>) -> Self {
        Self {
            cwd: cwd.into(),
            args: args.into(),
        }
    }

    /// Returns original diff selector args without pathspecs being refetched.
    fn forwarded_args<'a>(&'a self, paths: &[&Path]) -> Vec<&'a OsStr> {
        let args = self
            .args
            .iter()
            .position(|arg| arg.as_os_str() == OsStr::new(PATHSPEC_SEPARATOR))
            .map_or(self.args.as_slice(), |separator| &self.args[..separator]);

        args.iter()
            .map(OsString::as_os_str)
            .filter(|arg| !is_refetched_path(arg, paths))
            .collect()
    }
}

impl Repo for GitRepo {
    fn diff(&self, context_lines: usize, paths: &[&Path]) -> Result<Vec<u8>, RepoError> {
        // Forwarded args up to any `--` pathspec separator select the diff; the
        // refetch supplies its own narrowed path list after a fresh separator.
        let forwarded = self.forwarded_args(paths);
        let context = OsString::from(format!("-U{context_lines}"));
        let output = Command::new("git")
            .arg("diff")
            .args(forwarded)
            .arg(context)
            .arg(PATHSPEC_SEPARATOR)
            .args(paths)
            .current_dir(&self.cwd)
            .env(NO_COLOR, "1")
            .output()?;
        if !output.status.success() {
            return Err(RepoError::Status(output.status.code().unwrap_or(1)));
        }
        Ok(output.stdout)
    }
}

/// Returns whether an original argument is one of the pathspecs being refetched.
fn is_refetched_path(arg: &OsStr, paths: &[&Path]) -> bool {
    let arg = Path::new(arg);
    let normalized = arg.strip_prefix(".").unwrap_or(arg);

    paths.iter().any(|path| {
        let normalized_path = path.strip_prefix(".").unwrap_or(path);
        normalized == normalized_path
    })
}

// Error Handling //

/// A failure while refetching diff data from a [`Repo`].
#[derive(Debug)]
pub enum RepoError {
    /// Spawning or waiting on the backend failed.
    Io(io::Error),
    /// The backend exited with a failing status code.
    Status(i32),
}

impl From<io::Error> for RepoError {
    fn from(error: io::Error) -> Self {
        Self::Io(error)
    }
}

impl std::fmt::Display for RepoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(error) => write!(f, "{error}"),
            Self::Status(code) => write!(f, "git diff exited with status {code}"),
        }
    }
}

impl std::error::Error for RepoError {}

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

    #[test]
    fn forwarded_args_strip_refetched_pathspecs_without_separator() {
        let repo = GitRepo::new(
            ".",
            vec![
                OsString::from("HEAD~14"),
                OsString::from("HEAD"),
                OsString::from("assets/css/style.css"),
            ],
        );
        let path = Path::new("assets/css/style.css");

        let args = repo.forwarded_args(&[path]);

        assert_eq!(args, vec![OsStr::new("HEAD~14"), OsStr::new("HEAD")]);
    }

    #[test]
    fn forwarded_args_ignore_pathspecs_after_separator() {
        let repo = GitRepo::new(
            ".",
            vec![
                OsString::from("HEAD~14"),
                OsString::from("HEAD"),
                OsString::from("--"),
                OsString::from("assets/css/style.css"),
            ],
        );
        let path = Path::new("assets/css/style.css");

        let args = repo.forwarded_args(&[path]);

        assert_eq!(args, vec![OsStr::new("HEAD~14"), OsStr::new("HEAD")]);
    }
}