use std::ops::Range;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiffModel {
pub files: Vec<FileDiff>,
}
impl DiffModel {
pub fn fingerprint(&self) -> String {
let mut buf = Vec::new();
for file in &self.files {
buf.extend_from_slice(file.path.as_bytes());
buf.push(0);
buf.extend_from_slice(file.sides_hash().as_bytes());
buf.push(b'\n');
}
git2::Oid::hash_object(git2::ObjectType::Blob, &buf)
.map(|o| o.to_string())
.unwrap_or_default()
}
pub fn find_line(&self, file: &str, line: u32, on_old_side: bool) -> Option<&DiffLine> {
let file = self.files.iter().find(|f| f.path == file)?;
file.hunks.iter().flat_map(|h| &h.lines).find(|l| {
let no = if on_old_side { l.old_no } else { l.new_no };
no == Some(line)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDiff {
pub path: String,
pub old_path: Option<String>,
pub status: FileStatus,
pub binary: bool,
pub old_text: Option<String>,
pub new_text: Option<String>,
pub hunks: Vec<Hunk>,
}
impl FileDiff {
pub fn content_hash(&self) -> String {
let bytes = self.new_text.as_deref().unwrap_or("").as_bytes();
git2::Oid::hash_object(git2::ObjectType::Blob, bytes)
.map(|o| o.to_string())
.unwrap_or_default()
}
pub fn diffstat(&self) -> (usize, usize) {
let mut added = 0;
let mut deleted = 0;
for line in self.hunks.iter().flat_map(|h| &h.lines) {
match line.kind {
LineKind::Added => added += 1,
LineKind::Deleted => deleted += 1,
LineKind::Context => {}
}
}
(added, deleted)
}
pub fn sides_hash(&self) -> String {
let mut bytes = Vec::from(self.old_text.as_deref().unwrap_or("").as_bytes());
bytes.push(0);
bytes.extend_from_slice(self.new_text.as_deref().unwrap_or("").as_bytes());
git2::Oid::hash_object(git2::ObjectType::Blob, &bytes)
.map(|o| o.to_string())
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
Untracked,
}
impl FileStatus {
pub const fn glyph(self) -> char {
match self {
Self::Added => 'A',
Self::Modified => 'M',
Self::Deleted => 'D',
Self::Renamed => 'R',
Self::Untracked => '?',
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Added => "new file",
Self::Modified => "modified",
Self::Deleted => "deleted",
Self::Renamed => "renamed",
Self::Untracked => "untracked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct HunkId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
pub id: HunkId,
pub old_start: u32,
pub old_lines: u32,
pub new_start: u32,
pub new_lines: u32,
pub context: String,
pub lines: Vec<DiffLine>,
}
impl Hunk {
pub fn header(&self) -> String {
format!(
"@@ -{},{} +{},{} @@",
self.old_start, self.old_lines, self.new_start, self.new_lines
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LineKind {
Context,
Deleted,
Added,
}
impl LineKind {
pub const fn origin(self) -> char {
match self {
Self::Context => ' ',
Self::Deleted => '-',
Self::Added => '+',
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub kind: LineKind,
pub old_no: Option<u32>,
pub new_no: Option<u32>,
pub text: String,
pub emphasis: Vec<Range<usize>>,
pub moved: bool,
}
impl DiffLine {
pub fn new(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: String) -> Self {
Self {
kind,
old_no,
new_no,
text,
emphasis: Vec::new(),
moved: false,
}
}
}
pub fn hunk_id(file_path: &str, lines: &[DiffLine]) -> Result<HunkId, git2::Error> {
let mut buf = String::new();
buf.push_str(file_path);
buf.push('\n');
for line in lines {
let tag = match line.kind {
LineKind::Context => ' ',
LineKind::Deleted => '-',
LineKind::Added => '+',
};
buf.push(tag);
buf.push_str(&line.text);
buf.push('\n');
}
let oid = git2::Oid::hash_object(git2::ObjectType::Blob, buf.as_bytes())?;
Ok(HunkId(oid.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_status_glyph_and_label_cover_all_variants() {
assert_eq!(FileStatus::Added.glyph(), 'A');
assert_eq!(FileStatus::Modified.glyph(), 'M');
assert_eq!(FileStatus::Deleted.glyph(), 'D');
assert_eq!(FileStatus::Renamed.glyph(), 'R');
assert_eq!(FileStatus::Untracked.glyph(), '?');
assert_eq!(FileStatus::Added.label(), "new file");
assert_eq!(FileStatus::Modified.label(), "modified");
assert_eq!(FileStatus::Deleted.label(), "deleted");
assert_eq!(FileStatus::Renamed.label(), "renamed");
assert_eq!(FileStatus::Untracked.label(), "untracked");
}
fn line(kind: LineKind, text: &str) -> DiffLine {
DiffLine::new(kind, None, None, text.to_owned())
}
#[test]
fn hunk_id_is_stable() {
let lines = vec![line(LineKind::Deleted, "a"), line(LineKind::Added, "b")];
let id1 = hunk_id("src/x.rs", &lines).expect("hash");
let id2 = hunk_id("src/x.rs", &lines).expect("hash");
assert_eq!(id1, id2);
}
#[test]
fn hunk_id_changes_with_content() {
let a = vec![line(LineKind::Added, "x")];
let b = vec![line(LineKind::Added, "y")];
assert_ne!(
hunk_id("f", &a).expect("hash"),
hunk_id("f", &b).expect("hash")
);
}
#[test]
fn hunk_id_changes_with_kind() {
let a = vec![line(LineKind::Added, "x")];
let b = vec![line(LineKind::Deleted, "x")];
assert_ne!(
hunk_id("f", &a).expect("hash"),
hunk_id("f", &b).expect("hash")
);
}
#[test]
fn hunk_id_changes_with_file() {
let lines = vec![line(LineKind::Added, "x")];
assert_ne!(
hunk_id("a", &lines).expect("hash"),
hunk_id("b", &lines).expect("hash")
);
}
#[test]
fn header_formats() {
let hunk = Hunk {
id: HunkId("h".into()),
old_start: 10,
old_lines: 7,
new_start: 10,
new_lines: 9,
context: String::new(),
lines: vec![],
};
assert_eq!(hunk.header(), "@@ -10,7 +10,9 @@");
}
#[test]
fn content_hash_changes_when_new_text_changes() {
let base = FileDiff {
path: "f.rs".into(),
old_path: None,
status: FileStatus::Modified,
binary: false,
old_text: None,
new_text: Some("fn main() {}".into()),
hunks: vec![],
};
let mut changed = base.clone();
changed.new_text = Some("fn main() { let x = 1; }".into());
assert_ne!(base.content_hash(), changed.content_hash());
}
#[test]
fn content_hash_is_stable() {
let file = FileDiff {
path: "f.rs".into(),
old_path: None,
status: FileStatus::Modified,
binary: false,
old_text: None,
new_text: Some("same content".into()),
hunks: vec![],
};
assert_eq!(file.content_hash(), file.content_hash());
}
#[test]
fn sides_hash_changes_when_old_text_changes() {
let base = FileDiff {
path: "f.rs".into(),
old_path: None,
status: FileStatus::Modified,
binary: false,
old_text: Some("fn main() {}".into()),
new_text: Some("fn main() { let x = 1; }".into()),
hunks: vec![],
};
let mut changed = base.clone();
changed.old_text = Some("fn main() { unreachable!() }".into());
assert_eq!(
base.content_hash(),
changed.content_hash(),
"same new side, same content hash"
);
assert_ne!(base.sides_hash(), changed.sides_hash());
}
fn one_file_model(path: &str, old_text: &str, new_text: &str) -> DiffModel {
DiffModel {
files: vec![FileDiff {
path: path.to_owned(),
old_path: None,
status: FileStatus::Modified,
binary: false,
old_text: Some(old_text.to_owned()),
new_text: Some(new_text.to_owned()),
hunks: vec![],
}],
}
}
#[test]
fn fingerprint_is_stable_for_identical_models() {
let a = one_file_model("f.rs", "old", "new");
let b = one_file_model("f.rs", "old", "new");
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn fingerprint_changes_with_content_path_and_file_set() {
let base = one_file_model("f.rs", "old", "new");
assert_ne!(
base.fingerprint(),
one_file_model("f.rs", "old", "newer").fingerprint(),
"changed side changes the fingerprint"
);
assert_ne!(
base.fingerprint(),
one_file_model("g.rs", "old", "new").fingerprint(),
"renamed file changes the fingerprint"
);
let mut grown = base.clone();
grown.files.extend(one_file_model("g.rs", "", "x").files);
assert_ne!(
base.fingerprint(),
grown.fingerprint(),
"added file changes the fingerprint"
);
}
fn model_with_lines() -> DiffModel {
DiffModel {
files: vec![FileDiff {
path: "f.rs".into(),
old_path: None,
status: FileStatus::Modified,
binary: false,
old_text: None,
new_text: None,
hunks: vec![Hunk {
id: HunkId("h".into()),
old_start: 1,
old_lines: 2,
new_start: 1,
new_lines: 2,
context: String::new(),
lines: vec![
DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
],
}],
}],
}
}
#[test]
fn diffstat_counts_added_and_deleted_over_hunks() {
let model = model_with_lines();
assert_eq!(model.files[0].diffstat(), (1, 1));
let file = FileDiff {
path: "f.rs".into(),
old_path: None,
status: FileStatus::Modified,
binary: false,
old_text: None,
new_text: None,
hunks: vec![
Hunk {
id: HunkId("a".into()),
old_start: 1,
old_lines: 1,
new_start: 1,
new_lines: 2,
context: String::new(),
lines: vec![
DiffLine::new(LineKind::Context, Some(1), Some(1), "ctx".into()),
DiffLine::new(LineKind::Added, None, Some(2), "add one".into()),
],
},
Hunk {
id: HunkId("b".into()),
old_start: 5,
old_lines: 1,
new_start: 6,
new_lines: 1,
context: String::new(),
lines: vec![
DiffLine::new(LineKind::Deleted, Some(5), None, "gone".into()),
DiffLine::new(LineKind::Added, None, Some(6), "add two".into()),
],
},
],
};
assert_eq!(file.diffstat(), (2, 1));
}
#[test]
fn find_line_matches_the_requested_side() {
let model = model_with_lines();
let new_side = model.find_line("f.rs", 2, false).expect("new side");
assert_eq!(new_side.text, "TWO");
let old_side = model.find_line("f.rs", 2, true).expect("old side");
assert_eq!(old_side.text, "two");
}
#[test]
fn find_line_misses_unknown_files_and_lines() {
let model = model_with_lines();
assert!(model.find_line("nope.rs", 1, false).is_none());
assert!(model.find_line("f.rs", 99, false).is_none());
}
#[test]
fn content_hash_falls_back_for_none() {
let file = FileDiff {
path: "f.rs".into(),
old_path: None,
status: FileStatus::Deleted,
binary: false,
old_text: None,
new_text: None,
hunks: vec![],
};
let hash = file.content_hash();
assert!(!hash.is_empty());
}
}