Skip to main content

gunnar_crypt/
diff.rs

1//! Line diffs of DECRYPTED content, computed where the key is: in the CLI
2//! (`gunnar crypt diff`) and in the browser console's wasm. The server never
3//! sees plaintext, so it cannot diff an encrypted repository; there are no
4//! pre-made diffs either (they would only cost space). Both sides run this
5//! same function, so a diff reads the same in the terminal and in the browser.
6//!
7//! The shape matches the console's server-side diff (gunnar-ui-server
8//! `git/diff.rs`): histogram diff, 3 lines of context, hunks closer than 7
9//! lines merged, git's `@@` numbering.
10
11use imara_diff::{Algorithm, Diff, InternedInput};
12
13/// Lines of unchanged context around a change.
14pub const CONTEXT: usize = 3;
15/// Two changes closer than this share one hunk.
16pub const MERGE_GAP: usize = CONTEXT * 2 + 1;
17
18/// What one line of a hunk is.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LineKind {
21    /// Unchanged.
22    Context,
23    /// Only in the new version.
24    Add,
25    /// Only in the old version.
26    Del,
27}
28
29impl LineKind {
30    /// `context`, `add` or `del` — the console's names.
31    pub fn as_str(self) -> &'static str {
32        match self {
33            LineKind::Context => "context",
34            LineKind::Add => "add",
35            LineKind::Del => "del",
36        }
37    }
38}
39
40/// One line of a hunk.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Line {
43    /// What it is.
44    pub kind: LineKind,
45    /// 1-based line number in the old version.
46    pub old_no: Option<usize>,
47    /// 1-based line number in the new version.
48    pub new_no: Option<usize>,
49    /// The text, without its newline.
50    pub text: String,
51}
52
53/// A run of changed lines with its context.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Hunk {
56    /// git's `-a` (0 when the old side has no lines).
57    pub old_start: usize,
58    /// git's `,b`.
59    pub old_lines: usize,
60    /// git's `+c` (0 when the new side has no lines).
61    pub new_start: usize,
62    /// git's `,d`.
63    pub new_lines: usize,
64    /// The lines.
65    pub lines: Vec<Line>,
66}
67
68/// A whole-file comparison.
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct FileDiff {
71    /// The hunks, in order.
72    pub hunks: Vec<Hunk>,
73    /// Lines added.
74    pub insertions: usize,
75    /// Lines removed.
76    pub deletions: usize,
77}
78
79/// Line-level hunks between two texts.
80pub fn hunks(before: &str, after: &str) -> FileDiff {
81    let before_lines: Vec<&str> = before.lines().collect();
82    let after_lines: Vec<&str> = after.lines().collect();
83
84    let input = InternedInput::new(before, after);
85    let mut diff = Diff::compute(Algorithm::Histogram, &input);
86    diff.postprocess_lines(&input);
87
88    let raw: Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> = diff
89        .hunks()
90        .map(|h| {
91            (
92                h.before.start as usize..h.before.end as usize,
93                h.after.start as usize..h.after.end as usize,
94            )
95        })
96        .collect();
97
98    let insertions = raw.iter().map(|(_, a)| a.len()).sum();
99    let deletions = raw.iter().map(|(b, _)| b.len()).sum();
100
101    let mut groups: Vec<Vec<usize>> = Vec::new();
102    for (index, (before_range, _)) in raw.iter().enumerate() {
103        let joins = groups.last().is_some_and(|group| {
104            let last = &raw[*group.last().expect("a group is never empty")].0;
105            before_range.start.saturating_sub(last.end) <= MERGE_GAP
106        });
107        if joins {
108            groups.last_mut().expect("checked just above").push(index);
109        } else {
110            groups.push(vec![index]);
111        }
112    }
113
114    let mut out = Vec::new();
115    for group in groups {
116        let first = &raw[group[0]];
117        let last = &raw[*group.last().expect("a group is never empty")];
118        let old_from = first.0.start.saturating_sub(CONTEXT);
119        let old_to = (last.0.end + CONTEXT).min(before_lines.len());
120        let new_from = first.1.start.saturating_sub(CONTEXT);
121        let new_to = (last.1.end + CONTEXT).min(after_lines.len());
122
123        let mut lines = Vec::new();
124        let mut old_at = old_from;
125        let mut new_at = new_from;
126        for index in &group {
127            let (before_range, after_range) = &raw[*index];
128            while old_at < before_range.start {
129                lines.push(Line {
130                    kind: LineKind::Context,
131                    old_no: Some(old_at + 1),
132                    new_no: Some(new_at + 1),
133                    text: before_lines[old_at].to_owned(),
134                });
135                old_at += 1;
136                new_at += 1;
137            }
138            for line in before_range.clone() {
139                lines.push(Line {
140                    kind: LineKind::Del,
141                    old_no: Some(line + 1),
142                    new_no: None,
143                    text: before_lines[line].to_owned(),
144                });
145            }
146            for line in after_range.clone() {
147                lines.push(Line {
148                    kind: LineKind::Add,
149                    old_no: None,
150                    new_no: Some(line + 1),
151                    text: after_lines[line].to_owned(),
152                });
153            }
154            old_at = before_range.end;
155            new_at = after_range.end;
156        }
157        while old_at < old_to && new_at < new_to {
158            lines.push(Line {
159                kind: LineKind::Context,
160                old_no: Some(old_at + 1),
161                new_no: Some(new_at + 1),
162                text: before_lines[old_at].to_owned(),
163            });
164            old_at += 1;
165            new_at += 1;
166        }
167        let old_lines = old_at - old_from;
168        let new_lines = new_at - new_from;
169        out.push(Hunk {
170            old_start: if old_lines == 0 { 0 } else { old_from + 1 },
171            old_lines,
172            new_start: if new_lines == 0 { 0 } else { new_from + 1 },
173            new_lines,
174            lines,
175        });
176    }
177    FileDiff {
178        hunks: out,
179        insertions,
180        deletions,
181    }
182}
183
184/// The hunks as unified-diff text (`@@ -a,b +c,d @@`, then ` `/`+`/`-` lines).
185pub fn unified(diff: &FileDiff) -> String {
186    let mut s = String::new();
187    for h in &diff.hunks {
188        s.push_str(&format!(
189            "@@ -{},{} +{},{} @@\n",
190            h.old_start, h.old_lines, h.new_start, h.new_lines
191        ));
192        for l in &h.lines {
193            s.push(match l.kind {
194                LineKind::Context => ' ',
195                LineKind::Add => '+',
196                LineKind::Del => '-',
197            });
198            s.push_str(&l.text);
199            s.push('\n');
200        }
201    }
202    s
203}