Skip to main content

rustik_repo/
lib.rs

1//! Repository abstraction layer for Rustik tools.
2//!
3//! Expanding hunk context means rerunning a diff at a different unified-context
4//! width. The [`Repo`] trait keeps that repository access behind a small
5//! boundary so diff viewers stay I/O-free and testable; [`GitRepo`] is the
6//! `git diff` implementation used by the `rustik-diff` pager. The trait yields
7//! raw unified-diff bytes — parsing is left to the caller, so this crate
8//! carries no diff-format dependency.
9#![deny(unsafe_code)]
10#![warn(missing_docs)]
11#![warn(clippy::unwrap_used)]
12
13use std::ffi::{OsStr, OsString};
14use std::io;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18// Constants //
19
20/// Environment variable that disables Git's colored output.
21const NO_COLOR: &str = "NO_COLOR";
22/// Pathspec separator that ends Git's option list.
23const PATHSPEC_SEPARATOR: &str = "--";
24
25// Repo //
26
27/// A diff backend a viewer can re-query for wider hunk context.
28pub trait Repo {
29    /// Refetches `paths` with `context_lines` of unified context per hunk.
30    ///
31    /// Returns the raw unified-diff bytes; decoding them is the caller's job.
32    fn diff(&self, context_lines: usize, paths: &[&Path]) -> Result<Vec<u8>, RepoError>;
33}
34
35/// A [`Repo`] backed by the `git diff` command.
36pub struct GitRepo {
37    /// Working directory for `git diff` invocations.
38    cwd: PathBuf,
39    /// CLI arguments forwarded to Git, as the pager was invoked.
40    args: Vec<OsString>,
41}
42
43impl GitRepo {
44    /// Creates a repo that runs `git diff` in `cwd` with forwarded `args`.
45    pub fn new(cwd: impl Into<PathBuf>, args: impl Into<Vec<OsString>>) -> Self {
46        Self {
47            cwd: cwd.into(),
48            args: args.into(),
49        }
50    }
51
52    /// Returns original diff selector args without pathspecs being refetched.
53    fn forwarded_args<'a>(&'a self, paths: &[&Path]) -> Vec<&'a OsStr> {
54        let args = self
55            .args
56            .iter()
57            .position(|arg| arg.as_os_str() == OsStr::new(PATHSPEC_SEPARATOR))
58            .map_or(self.args.as_slice(), |separator| &self.args[..separator]);
59
60        args.iter()
61            .map(OsString::as_os_str)
62            .filter(|arg| !is_refetched_path(arg, paths))
63            .collect()
64    }
65}
66
67impl Repo for GitRepo {
68    fn diff(&self, context_lines: usize, paths: &[&Path]) -> Result<Vec<u8>, RepoError> {
69        // Forwarded args up to any `--` pathspec separator select the diff; the
70        // refetch supplies its own narrowed path list after a fresh separator.
71        let forwarded = self.forwarded_args(paths);
72        let context = OsString::from(format!("-U{context_lines}"));
73        let output = Command::new("git")
74            .arg("diff")
75            .args(forwarded)
76            .arg(context)
77            .arg(PATHSPEC_SEPARATOR)
78            .args(paths)
79            .current_dir(&self.cwd)
80            .env(NO_COLOR, "1")
81            .output()?;
82        if !output.status.success() {
83            return Err(RepoError::Status(output.status.code().unwrap_or(1)));
84        }
85        Ok(output.stdout)
86    }
87}
88
89/// Returns whether an original argument is one of the pathspecs being refetched.
90fn is_refetched_path(arg: &OsStr, paths: &[&Path]) -> bool {
91    let arg = Path::new(arg);
92    let normalized = arg.strip_prefix(".").unwrap_or(arg);
93
94    paths.iter().any(|path| {
95        let normalized_path = path.strip_prefix(".").unwrap_or(path);
96        normalized == normalized_path
97    })
98}
99
100// Error Handling //
101
102/// A failure while refetching diff data from a [`Repo`].
103#[derive(Debug)]
104pub enum RepoError {
105    /// Spawning or waiting on the backend failed.
106    Io(io::Error),
107    /// The backend exited with a failing status code.
108    Status(i32),
109}
110
111impl From<io::Error> for RepoError {
112    fn from(error: io::Error) -> Self {
113        Self::Io(error)
114    }
115}
116
117impl std::fmt::Display for RepoError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::Io(error) => write!(f, "{error}"),
121            Self::Status(code) => write!(f, "git diff exited with status {code}"),
122        }
123    }
124}
125
126impl std::error::Error for RepoError {}
127
128#[cfg(test)]
129#[allow(clippy::unwrap_used)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn forwarded_args_strip_refetched_pathspecs_without_separator() {
135        let repo = GitRepo::new(
136            ".",
137            vec![
138                OsString::from("HEAD~14"),
139                OsString::from("HEAD"),
140                OsString::from("assets/css/style.css"),
141            ],
142        );
143        let path = Path::new("assets/css/style.css");
144
145        let args = repo.forwarded_args(&[path]);
146
147        assert_eq!(args, vec![OsStr::new("HEAD~14"), OsStr::new("HEAD")]);
148    }
149
150    #[test]
151    fn forwarded_args_ignore_pathspecs_after_separator() {
152        let repo = GitRepo::new(
153            ".",
154            vec![
155                OsString::from("HEAD~14"),
156                OsString::from("HEAD"),
157                OsString::from("--"),
158                OsString::from("assets/css/style.css"),
159            ],
160        );
161        let path = Path::new("assets/css/style.css");
162
163        let args = repo.forwarded_args(&[path]);
164
165        assert_eq!(args, vec![OsStr::new("HEAD~14"), OsStr::new("HEAD")]);
166    }
167}