use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use clap::ValueEnum;
use crate::errors::{Error, Result};
use crate::io::files;
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DiffBackend {
#[default]
Git,
}
impl DiffBackend {
pub fn id(self) -> &'static str {
match self {
DiffBackend::Git => "git",
}
}
}
pub trait DiffProvider {
fn diffs(&self, root: &Path, files: &[PathBuf]) -> Result<BTreeMap<PathBuf, String>>;
}
pub fn provider(backend: DiffBackend) -> Box<dyn DiffProvider> {
match backend {
DiffBackend::Git => Box::new(GitDiff::new()),
}
}
pub struct GitDiff {
base: String,
git_bin: String,
}
impl Default for GitDiff {
fn default() -> Self {
GitDiff::new()
}
}
impl GitDiff {
pub fn new() -> Self {
GitDiff {
base: "HEAD".to_string(),
git_bin: "git".to_string(),
}
}
fn git(&self, root: &Path, args: &[&str]) -> Result<String> {
let output = Command::new(&self.git_bin)
.arg("-C")
.arg(root)
.args(args)
.output()
.map_err(|e| {
let message = if e.kind() == std::io::ErrorKind::NotFound {
"git not found on PATH; install git or choose another --diff backend"
.to_string()
} else {
format!("running git: {e}")
};
Error::Diff {
backend: DiffBackend::Git.id().to_string(),
message,
}
})?;
if !output.status.success() {
return Err(Error::Diff {
backend: DiffBackend::Git.id().to_string(),
message: format!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn base_exists(&self, root: &Path) -> bool {
Command::new(&self.git_bin)
.arg("-C")
.arg(root)
.args(["rev-parse", "--verify", "--quiet", &self.base])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
impl DiffProvider for GitDiff {
fn diffs(&self, root: &Path, files: &[PathBuf]) -> Result<BTreeMap<PathBuf, String>> {
let inside = self.git(root, &["rev-parse", "--is-inside-work-tree"])?;
if inside.trim() != "true" {
return Err(Error::Diff {
backend: DiffBackend::Git.id().to_string(),
message: format!("{} is not inside a git work tree", root.display()),
});
}
let base_arg = if self.base_exists(root) {
self.base.as_str()
} else {
"--cached"
};
let mut out = BTreeMap::new();
for file in files {
let rel = files::to_slash(file);
let args = ["diff", "--no-color", base_arg, "--", &rel];
let diff = self.git(root, &args)?;
if !diff.trim().is_empty() {
out.insert(file.clone(), diff);
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn init_repo(dir: &Path) {
git(dir, &["init", "-q"]);
git(dir, &["config", "user.email", "t@t.t"]);
git(dir, &["config", "user.name", "t"]);
git(dir, &["checkout", "-q", "-b", "main"]);
}
#[test]
fn backend_default_is_git() {
assert_eq!(DiffBackend::default(), DiffBackend::Git);
assert_eq!(DiffBackend::Git.id(), "git");
}
#[test]
fn git_diff_reports_only_changed_files() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
fs::write(root.join("b.rs"), "fn b() {}\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-q", "-m", "init"]);
fs::write(root.join("a.rs"), "fn a() { todo!() }\n").unwrap();
let provider = GitDiff::new();
let diffs = provider
.diffs(root, &[PathBuf::from("a.rs"), PathBuf::from("b.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
assert!(diffs[Path::new("a.rs")].contains("todo!()"));
assert!(!diffs.contains_key(Path::new("b.rs")), "got {diffs:?}");
}
#[test]
fn unborn_head_falls_back_to_worktree_diff() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
let diffs = GitDiff::new()
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")), "got {diffs:?}");
}
#[test]
fn non_repo_is_a_clear_error() {
let dir = tempdir().unwrap();
let err = GitDiff::new()
.diffs(dir.path(), &[PathBuf::from("a.rs")])
.unwrap_err();
assert!(matches!(err, Error::Diff { .. }), "got {err:?}");
assert!(err.to_string().contains("diff (git)"));
}
#[test]
fn bare_repo_is_not_a_work_tree() {
let dir = tempdir().unwrap();
git(dir.path(), &["init", "-q", "--bare"]);
let err = GitDiff::new()
.diffs(dir.path(), &[PathBuf::from("a.rs")])
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not inside a git work tree"), "got {msg}");
}
#[test]
fn missing_git_binary_is_a_clear_error() {
let dir = tempdir().unwrap();
let provider = GitDiff {
base: "HEAD".into(),
git_bin: "definitely-not-a-real-git-xyz".into(),
};
let err = provider
.diffs(dir.path(), &[PathBuf::from("a.rs")])
.unwrap_err();
assert!(err.to_string().contains("git not found"), "got {err}");
}
#[test]
fn provider_dispatches_git_backend() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-q", "-m", "init"]);
fs::write(root.join("a.rs"), "fn a() { 1; }\n").unwrap();
let diffs = provider(DiffBackend::Git)
.diffs(root, &[PathBuf::from("a.rs")])
.unwrap();
assert!(diffs.contains_key(Path::new("a.rs")));
}
#[test]
fn default_matches_new() {
let d = GitDiff::default();
assert_eq!(d.base, "HEAD");
assert_eq!(d.git_bin, "git");
}
}