crates/gunnar-crypt/src/diff.rs
//! Line diffs of DECRYPTED content, computed where the key is: in the CLI
//! (`gunnar crypt diff`) and in the browser console's wasm. The server never
//! sees plaintext, so it cannot diff an encrypted repository; there are no
//! pre-made diffs either (they would only cost space). Both sides run this
//! same function, so a diff reads the same in the terminal and in the browser.
//!
//! The shape matches the console's server-side diff (gunnar-ui-server
//! `git/diff.rs`): histogram diff, 3 lines of context, hunks closer than 7
//! lines merged, git's `@@` numbering.
use imara_diff::{Algorithm, Diff, InternedInput};
/// Lines of unchanged context around a change.
pub const CONTEXT: usize = 3;
/// Two changes closer than this share one hunk.
pub const MERGE_GAP: usize = CONTEXT * 2 + 1;
/// What one line of a hunk is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineKind {
/// Unchanged.
Context,
/// Only in the new version.
Add,
/// Only in the old version.
Del,
}
impl LineKind {
/// `context`, `add` or `del` — the console's names.
pub fn as_str(self) -> &'static str {
match self {
LineKind::Context => "context",
LineKind::Add => "add",
LineKind::Del => "del",
}
}
}
/// One line of a hunk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line {
/// What it is.
pub kind: LineKind,
/// 1-based line number in the old version.
pub old_no: Option<usize>,
/// 1-based line number in the new version.
pub new_no: Option<usize>,
/// The text, without its newline.
pub text: String,
}
/// A run of changed lines with its context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
/// git's `-a` (0 when the old side has no lines).
pub old_start: usize,
/// git's `,b`.
pub old_lines: usize,
/// git's `+c` (0 when the new side has no lines).
pub new_start: usize,
/// git's `,d`.
pub new_lines: usize,
/// The lines.
pub lines: Vec<Line>,
}
/// A whole-file comparison.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FileDiff {
/// The hunks, in order.
pub hunks: Vec<Hunk>,
/// Lines added.
pub insertions: usize,
/// Lines removed.
pub deletions: usize,
}
/// Line-level hunks between two texts.
pub fn hunks(before: &str, after: &str) -> FileDiff {
let before_lines: Vec<&str> = before.lines().collect();
let after_lines: Vec<&str> = after.lines().collect();
let input = InternedInput::new(before, after);
let mut diff = Diff::compute(Algorithm::Histogram, &input);
diff.postprocess_lines(&input);
let raw: Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> = diff
.hunks()
.map(|h| {
(
h.before.start as usize..h.before.end as usize,
h.after.start as usize..h.after.end as usize,
)
})
.collect();
let insertions = raw.iter().map(|(_, a)| a.len()).sum();
let deletions = raw.iter().map(|(b, _)| b.len()).sum();
let mut groups: Vec<Vec<usize>> = Vec::new();
for (index, (before_range, _)) in raw.iter().enumerate() {
let joins = groups.last().is_some_and(|group| {
let last = &raw[*group.last().expect("a group is never empty")].0;
before_range.start.saturating_sub(last.end) <= MERGE_GAP
});
if joins {
groups.last_mut().expect("checked just above").push(index);
} else {
groups.push(vec![index]);
}
}
let mut out = Vec::new();
for group in groups {
let first = &raw[group[0]];
let last = &raw[*group.last().expect("a group is never empty")];
let old_from = first.0.start.saturating_sub(CONTEXT);
let old_to = (last.0.end + CONTEXT).min(before_lines.len());
let new_from = first.1.start.saturating_sub(CONTEXT);
let new_to = (last.1.end + CONTEXT).min(after_lines.len());
let mut lines = Vec::new();
let mut old_at = old_from;
let mut new_at = new_from;
for index in &group {
let (before_range, after_range) = &raw[*index];
while old_at < before_range.start {
lines.push(Line {
kind: LineKind::Context,
old_no: Some(old_at + 1),
new_no: Some(new_at + 1),
text: before_lines[old_at].to_owned(),
});
old_at += 1;
new_at += 1;
}
for line in before_range.clone() {
lines.push(Line {
kind: LineKind::Del,
old_no: Some(line + 1),
new_no: None,
text: before_lines[line].to_owned(),
});
}
for line in after_range.clone() {
lines.push(Line {
kind: LineKind::Add,
old_no: None,
new_no: Some(line + 1),
text: after_lines[line].to_owned(),
});
}
old_at = before_range.end;
new_at = after_range.end;
}
while old_at < old_to && new_at < new_to {
lines.push(Line {
kind: LineKind::Context,
old_no: Some(old_at + 1),
new_no: Some(new_at + 1),
text: before_lines[old_at].to_owned(),
});
old_at += 1;
new_at += 1;
}
let old_lines = old_at - old_from;
let new_lines = new_at - new_from;
out.push(Hunk {
old_start: if old_lines == 0 { 0 } else { old_from + 1 },
old_lines,
new_start: if new_lines == 0 { 0 } else { new_from + 1 },
new_lines,
lines,
});
}
FileDiff {
hunks: out,
insertions,
deletions,
}
}
/// The hunks as unified-diff text (`@@ -a,b +c,d @@`, then ` `/`+`/`-` lines).
pub fn unified(diff: &FileDiff) -> String {
let mut s = String::new();
for h in &diff.hunks {
s.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
h.old_start, h.old_lines, h.new_start, h.new_lines
));
for l in &h.lines {
s.push(match l.kind {
LineKind::Context => ' ',
LineKind::Add => '+',
LineKind::Del => '-',
});
s.push_str(&l.text);
s.push('\n');
}
}
s
}