Skip to main content

gunnar_server/
diff.rs

1//! **The tree diff `Refs.Diff` serves** (the event stream).
2//!
3//! Until this module existed the RPC answered:
4//!
5//! > `Refs.Diff`: the server has no tree-diff engine; gix's blob-diff is
6//! > client-side porcelain and an S crate must not link it
7//!
8//! The second half of that sentence is still true and is why this file is here
9//! rather than a dependency line. What it got wrong was the conclusion: a tree
10//! diff is not blob porcelain. It is a walk over two trees, and the only thing
11//! it needs from a repository is `GitServe::read` — which every engine has,
12//! which the appliance already links, and which costs no crate.
13//!
14//! # What it computes, and what it does not
15//!
16//! * **Structure, exactly**: which paths were added, deleted, modified or
17//!   changed type, with both object ids and both raw modes. This is what a
18//!   change proposal is built on and it is never approximated.
19//! * **Line counts, exactly**, by way of [`edit_counts`] — a bounded Myers
20//!   walk. Where the bound is hit the answer is a genuine *upper bound* (every
21//!   old line deleted, every new line inserted) and never a zero: a `0/0` on a
22//!   file that changed is the shape of "we did not look", and it is
23//!   indistinguishable from "nothing happened" to whoever reads it.
24//! * **No rename detection.** `RENAMED` and `COPIED` exist in the proto and are
25//!   never emitted: a rename is a *heuristic* over content similarity, and
26//!   inventing one here would make the answer depend on a threshold nobody
27//!   chose. A rename is reported the way `git diff` without `-M` reports it —
28//!   one `DELETED` and one `ADDED` — which is the truth about the trees.
29
30use std::collections::BTreeMap;
31
32use gunnar_proto::{ChangeKind, FileChange};
33use gunnar_store::git_storage_trait::{GitServe, ObjType};
34use gunnar_store::object::{EntryKind, NamedTreeEntry, TreeIter};
35
36/// How much of a blob is inspected for a NUL before it is called binary.
37///
38/// git's own number (`buffer_is_binary` reads the first 8000 bytes). Matching
39/// it matters more than the value does: a file that `git diff` calls binary and
40/// gunnar calls text is a difference two people will argue about.
41const BINARY_PROBE_BYTES: usize = 8000;
42
43/// The largest edit distance [`edit_counts`] will walk before giving the
44/// upper-bound answer.
45///
46/// Myers is `O((N+M)·D)`, so the bound is on `D` — the number of changed lines
47/// — and not on file size. A file of any length whose change is small is walked
48/// exactly; a file whose change is enormous is the case where a minimal script
49/// is both expensive and useless, since nobody reads a 100 000-line hunk.
50const MAX_EDIT_DISTANCE: usize = 4096;
51
52/// One side of the walk: a tree's entries by name.
53type Entries = BTreeMap<Vec<u8>, Owned>;
54
55/// A [`NamedTreeEntry`] that owns its name, so the tree buffer can be dropped.
56#[derive(Debug, Clone)]
57struct Owned {
58    oid: gix_hash::ObjectId,
59    kind: EntryKind,
60    mode: u32,
61}
62
63/// Everything that went wrong reading a repository during a diff.
64#[derive(Debug)]
65pub enum DiffError {
66    /// The object is not in this repository.
67    Missing(gix_hash::ObjectId),
68    /// The object is here and is not the kind the walk needed.
69    WrongKind {
70        oid: gix_hash::ObjectId,
71        want: &'static str,
72        got: ObjType,
73    },
74    /// The store could not answer.
75    Backend(String),
76    /// A tree object did not decode.
77    Corrupt {
78        oid: gix_hash::ObjectId,
79        reason: String,
80    },
81}
82
83impl std::fmt::Display for DiffError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            DiffError::Missing(oid) => write!(f, "object {oid} is not in this repository"),
87            DiffError::WrongKind { oid, want, got } => {
88                write!(f, "object {oid} is a {got:?} where a {want} was needed")
89            }
90            DiffError::Backend(why) => write!(f, "the repository could not be read: {why}"),
91            DiffError::Corrupt { oid, reason } => write!(f, "tree {oid} does not decode: {reason}"),
92        }
93    }
94}
95
96type Result<T> = std::result::Result<T, DiffError>;
97
98/// Compare two trees and report every path that differs.
99///
100/// `path_prefixes` is a filter in the caller's terms: a change is reported when
101/// its path starts with any of them, and an empty list means everything. The
102/// walk **prunes** on it as well as filtering — a directory that no prefix can
103/// reach is never read — which is what keeps a one-file diff of a large
104/// repository a one-file cost.
105///
106/// The output is ordered by path, because the walk is: `BTreeMap` at every
107/// level and depth-first in name order. A caller that sorted the result again
108/// would be sorting a sorted list, and a caller that relied on insertion order
109/// would be relying on nothing.
110pub fn compare(
111    git: &dyn GitServe,
112    hash: gix_hash::Kind,
113    old_tree: Option<gix_hash::ObjectId>,
114    new_tree: Option<gix_hash::ObjectId>,
115    path_prefixes: &[String],
116) -> Result<Vec<FileChange>> {
117    let mut out = Vec::new();
118    walk(git, hash, "", old_tree, new_tree, path_prefixes, &mut out)?;
119    Ok(out)
120}
121
122/// Does any filter reach into `dir`, or does `dir` reach into any filter?
123///
124/// Both directions are needed. `src/` must be walked for the filter
125/// `src/lib.rs` (the filter reaches in), and it must also be walked for the
126/// filter `src` (the directory is inside the filter). Checking only one of them
127/// silently drops half the matches, which reads as "nothing changed there".
128fn dir_is_interesting(dir: &str, prefixes: &[String]) -> bool {
129    if prefixes.is_empty() || dir.is_empty() {
130        return true;
131    }
132    prefixes
133        .iter()
134        .any(|p| p.starts_with(dir) || dir.starts_with(p.as_str()))
135}
136
137fn path_matches(path: &str, prefixes: &[String]) -> bool {
138    prefixes.is_empty() || prefixes.iter().any(|p| path.starts_with(p.as_str()))
139}
140
141fn read(git: &dyn GitServe, oid: gix_hash::ObjectId) -> Result<(ObjType, Vec<u8>)> {
142    git.read(oid.as_bytes())
143        .map_err(|err| DiffError::Backend(err.to_string()))?
144        .ok_or(DiffError::Missing(oid))
145}
146
147fn entries(git: &dyn GitServe, hash: gix_hash::Kind, tree: gix_hash::ObjectId) -> Result<Entries> {
148    let (kind, bytes) = read(git, tree)?;
149    if kind != ObjType::Tree {
150        return Err(DiffError::WrongKind {
151            oid: tree,
152            want: "tree",
153            got: kind,
154        });
155    }
156    let mut map = Entries::new();
157    for entry in TreeIter::from_bytes(&bytes, hash).named() {
158        let NamedTreeEntry {
159            oid,
160            kind,
161            name,
162            mode,
163        } = entry.map_err(|err| DiffError::Corrupt {
164            oid: tree,
165            reason: err.to_string(),
166        })?;
167        map.insert(name.to_vec(), Owned { oid, kind, mode });
168    }
169    Ok(map)
170}
171
172fn join(dir: &str, name: &[u8]) -> String {
173    // Lossy, once, at the boundary. git does not promise a path is UTF-8 and
174    // the proto field is a `string`, so the conversion has to happen somewhere;
175    // here it is visible rather than buried in a `Display`.
176    let name = String::from_utf8_lossy(name);
177    if dir.is_empty() {
178        name.into_owned()
179    } else {
180        format!("{dir}/{name}")
181    }
182}
183
184#[allow(clippy::too_many_arguments)]
185fn walk(
186    git: &dyn GitServe,
187    hash: gix_hash::Kind,
188    dir: &str,
189    old_tree: Option<gix_hash::ObjectId>,
190    new_tree: Option<gix_hash::ObjectId>,
191    prefixes: &[String],
192    out: &mut Vec<FileChange>,
193) -> Result<()> {
194    // **The one optimisation that makes this affordable**: two trees with the
195    // same id have the same contents, all the way down. Reading them to find
196    // that out is the difference between a diff of one commit and a diff of the
197    // repository.
198    if old_tree == new_tree {
199        return Ok(());
200    }
201    if !dir_is_interesting(dir, prefixes) {
202        return Ok(());
203    }
204
205    let old = match old_tree {
206        Some(id) => entries(git, hash, id)?,
207        None => Entries::new(),
208    };
209    let new = match new_tree {
210        Some(id) => entries(git, hash, id)?,
211        None => Entries::new(),
212    };
213
214    // Every name on either side, in order, each visited once.
215    let mut names: Vec<&Vec<u8>> = old.keys().chain(new.keys()).collect();
216    names.sort_unstable();
217    names.dedup();
218
219    for name in names {
220        let path = join(dir, name);
221        match (old.get(name), new.get(name)) {
222            (Some(a), Some(b)) => {
223                match (a.kind == EntryKind::Tree, b.kind == EntryKind::Tree) {
224                    (true, true) => {
225                        walk(git, hash, &path, Some(a.oid), Some(b.oid), prefixes, out)?
226                    }
227                    // A directory became a file or the other way round. Reported
228                    // as the removal of everything under the tree plus the
229                    // arrival of the file, because that is what happened to the
230                    // paths — `TYPE_CHANGED` on the directory's own name would
231                    // hide every file that went with it.
232                    (true, false) => {
233                        walk(git, hash, &path, Some(a.oid), None, prefixes, out)?;
234                        emit_added(git, &path, b, prefixes, out)?;
235                    }
236                    (false, true) => {
237                        emit_deleted(git, &path, a, prefixes, out)?;
238                        walk(git, hash, &path, None, Some(b.oid), prefixes, out)?;
239                    }
240                    (false, false) => {
241                        if a.oid == b.oid && a.mode == b.mode {
242                            continue;
243                        }
244                        if !path_matches(&path, prefixes) {
245                            continue;
246                        }
247                        // A blob that became a symlink, or either becoming a
248                        // gitlink: the path survives and what lives there does
249                        // not. `MODIFIED` would invite a line diff between two
250                        // things that are not the same kind of thing.
251                        let kind = if a.kind != b.kind {
252                            ChangeKind::TypeChanged
253                        } else {
254                            ChangeKind::Modified
255                        };
256                        let (insertions, deletions, binary) =
257                            if kind == ChangeKind::TypeChanged || a.oid == b.oid {
258                                // Same bytes, different mode: no lines moved,
259                                // and that 0/0 is a measurement rather than a
260                                // gap.
261                                (0, 0, false)
262                            } else {
263                                line_counts(git, Some(a.oid), Some(b.oid))?
264                            };
265                        out.push(FileChange {
266                            kind: kind as i32,
267                            path,
268                            old_path: String::new(),
269                            old_oid: a.oid.to_string(),
270                            new_oid: b.oid.to_string(),
271                            old_mode: a.mode,
272                            new_mode: b.mode,
273                            insertions,
274                            deletions,
275                            binary,
276                        });
277                    }
278                }
279            }
280            (Some(a), None) => {
281                if a.kind == EntryKind::Tree {
282                    walk(git, hash, &path, Some(a.oid), None, prefixes, out)?;
283                } else {
284                    emit_deleted(git, &path, a, prefixes, out)?;
285                }
286            }
287            (None, Some(b)) => {
288                if b.kind == EntryKind::Tree {
289                    walk(git, hash, &path, None, Some(b.oid), prefixes, out)?;
290                } else {
291                    emit_added(git, &path, b, prefixes, out)?;
292                }
293            }
294            (None, None) => unreachable!("the name came from one of the two maps"),
295        }
296    }
297    Ok(())
298}
299
300fn emit_added(
301    git: &dyn GitServe,
302    path: &str,
303    entry: &Owned,
304    prefixes: &[String],
305    out: &mut Vec<FileChange>,
306) -> Result<()> {
307    if !path_matches(path, prefixes) {
308        return Ok(());
309    }
310    let (insertions, deletions, binary) = line_counts(git, None, Some(entry.oid))?;
311    out.push(FileChange {
312        kind: ChangeKind::Added as i32,
313        path: path.to_owned(),
314        old_path: String::new(),
315        old_oid: String::new(),
316        new_oid: entry.oid.to_string(),
317        old_mode: 0,
318        new_mode: entry.mode,
319        insertions,
320        deletions,
321        binary,
322    });
323    Ok(())
324}
325
326fn emit_deleted(
327    git: &dyn GitServe,
328    path: &str,
329    entry: &Owned,
330    prefixes: &[String],
331    out: &mut Vec<FileChange>,
332) -> Result<()> {
333    if !path_matches(path, prefixes) {
334        return Ok(());
335    }
336    let (insertions, deletions, binary) = line_counts(git, Some(entry.oid), None)?;
337    out.push(FileChange {
338        kind: ChangeKind::Deleted as i32,
339        path: path.to_owned(),
340        old_path: String::new(),
341        old_oid: entry.oid.to_string(),
342        new_oid: String::new(),
343        old_mode: entry.mode,
344        new_mode: 0,
345        insertions,
346        deletions,
347        binary,
348    });
349    Ok(())
350}
351
352/// `(insertions, deletions, binary)` for one path.
353///
354/// A gitlink has no blob to read — its "oid" names a commit in another
355/// repository — so it is reported as a zero-line, non-binary change. That is
356/// the honest answer: the submodule pointer moved and no line in *this*
357/// repository did.
358fn line_counts(
359    git: &dyn GitServe,
360    old: Option<gix_hash::ObjectId>,
361    new: Option<gix_hash::ObjectId>,
362) -> Result<(u64, u64, bool)> {
363    let load = |id: Option<gix_hash::ObjectId>| -> Result<Option<Vec<u8>>> {
364        match id {
365            None => Ok(None),
366            Some(id) => match read(git, id)? {
367                (ObjType::Blob, bytes) => Ok(Some(bytes)),
368                // A gitlink, or a tree reached through a path that is not one.
369                // Neither has lines; neither is an error.
370                _ => Ok(None),
371            },
372        }
373    };
374    let old = load(old)?;
375    let new = load(new)?;
376    let binary = [old.as_deref(), new.as_deref()]
377        .into_iter()
378        .flatten()
379        .any(is_binary);
380    if binary {
381        return Ok((0, 0, true));
382    }
383    let old_lines = lines(old.as_deref().unwrap_or_default());
384    let new_lines = lines(new.as_deref().unwrap_or_default());
385    let (insertions, deletions) = edit_counts(&old_lines, &new_lines);
386    Ok((insertions as u64, deletions as u64, false))
387}
388
389/// git's own binary probe: a NUL in the first [`BINARY_PROBE_BYTES`] bytes.
390///
391/// `pub(crate)` so [`crate::search`] skips the same blobs a diff would call
392/// binary — one definition of "binary" for both, rather than two that can
393/// disagree about the same file (one implementation).
394pub(crate) fn is_binary(bytes: &[u8]) -> bool {
395    bytes.iter().take(BINARY_PROBE_BYTES).any(|byte| *byte == 0)
396}
397
398/// Split on `\n`, dropping the empty tail a trailing newline produces.
399///
400/// So a file of three newline-terminated lines is three lines and not four. A
401/// file with no trailing newline is the same three, which means the *absence*
402/// of a trailing newline is invisible to the counts — git reports it as a
403/// marker in the patch text rather than as a line, and there is no patch text
404/// here.
405fn lines(bytes: &[u8]) -> Vec<&[u8]> {
406    let mut out: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect();
407    if bytes.is_empty() || matches!(out.last(), Some(last) if last.is_empty()) {
408        out.pop();
409    }
410    out
411}
412
413/// **How many lines were inserted and how many deleted**, exactly.
414///
415/// # Why the edit *distance* is enough
416///
417/// Myers' greedy algorithm finds `D`, the length of the shortest edit script,
418/// which is `insertions + deletions`. The other equation is free: an edit
419/// script turns a sequence of length `n` into one of length `m`, so
420/// `insertions − deletions = m − n`. Two equations, two unknowns —
421///
422/// ```text
423///   insertions = (D + (m − n)) / 2
424///   deletions  = (D − (m − n)) / 2
425/// ```
426///
427/// — and `D` and `m − n` always have the same parity, so the division is exact.
428/// That means the full edit *script* never has to be materialised, and with it
429/// goes the `O(N·D)` memory that makes a naive Myers implementation fall over
430/// on a large file. This walk keeps one `V` array of `2D+1` entries.
431///
432/// # The bound, and why exceeding it is still an answer
433///
434/// Past [`MAX_EDIT_DISTANCE`] the walk stops and returns
435/// `(new.len(), old.len())` — delete everything, insert everything. That is a
436/// **valid** edit script, merely not the shortest one, so the numbers stay an
437/// upper bound on a real quantity. The alternative — reporting `0, 0` — is not
438/// a worse estimate, it is a different claim, and it is false.
439fn edit_counts(old: &[&[u8]], new: &[&[u8]]) -> (usize, usize) {
440    // Common prefix and suffix carry no edits and shrink the problem to the
441    // part that changed, which is what makes a one-line change in a large file
442    // cost one step instead of thousands.
443    let mut start = 0;
444    while start < old.len() && start < new.len() && old[start] == new[start] {
445        start += 1;
446    }
447    let mut end = 0;
448    while end < old.len() - start
449        && end < new.len() - start
450        && old[old.len() - 1 - end] == new[new.len() - 1 - end]
451    {
452        end += 1;
453    }
454    let a = &old[start..old.len() - end];
455    let b = &new[start..new.len() - end];
456
457    if a.is_empty() {
458        return (b.len(), 0);
459    }
460    if b.is_empty() {
461        return (0, a.len());
462    }
463
464    let (n, m) = (a.len(), b.len());
465    let max = (n + m).min(MAX_EDIT_DISTANCE);
466    // `v[k + max]` is the furthest `x` reached on diagonal `k`.
467    let mut v = vec![0usize; 2 * max + 2];
468    for d in 0..=max {
469        // `k` runs −d..=d in steps of two; the offset keeps the index positive.
470        let mut k = -(d as isize);
471        while k <= d as isize {
472            let idx = (k + max as isize) as usize;
473            let mut x = if k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]) {
474                v[idx + 1]
475            } else {
476                v[idx - 1] + 1
477            };
478            let mut y = (x as isize - k) as usize;
479            while x < n && y < m && a[x] == b[y] {
480                x += 1;
481                y += 1;
482            }
483            v[idx] = x;
484            if x >= n && y >= m {
485                // d == insertions + deletions, and m − n == insertions − deletions.
486                let delta = m as isize - n as isize;
487                let insertions = ((d as isize + delta) / 2) as usize;
488                let deletions = d - insertions;
489                return (insertions, deletions);
490            }
491            k += 2;
492        }
493    }
494    // The bound. An upper bound, and said as one.
495    (b.len(), a.len())
496}
497
498/// One step of an edit script, in old-file order.
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum Op {
501    /// `count` lines that are the same in both, starting at these offsets.
502    Equal {
503        old: usize,
504        new: usize,
505        count: usize,
506    },
507    /// `count` lines present only in the old file.
508    Delete { old: usize, count: usize },
509    /// `count` lines present only in the new file.
510    Insert { new: usize, count: usize },
511}
512
513/// **The full edit script**, not just its length.
514///
515/// [`edit_counts`] answers *how many* lines moved and needs only the edit
516/// distance; a blame needs *which*, so this is the same Myers walk with its
517/// trace kept and backtracked. It is a separate function because the trace
518/// costs `O(D²)` where the distance alone costs `O(D)`, and the common caller —
519/// a diff's line counts — must not pay for a script nobody reads.
520///
521/// Past [`MAX_EDIT_DISTANCE`] it returns the trivial script (delete everything,
522/// insert everything). That is a **valid** script, so a blame built on it
523/// attributes every line to the commit that rewrote the file — which is what
524/// actually happened when a file changes that much.
525pub fn diff_ops(old: &[&[u8]], new: &[&[u8]]) -> Vec<Op> {
526    let (n, m) = (old.len(), new.len());
527    if n == 0 && m == 0 {
528        return Vec::new();
529    }
530    let max = (n + m).min(MAX_EDIT_DISTANCE);
531    // `trace[d]` is the `V` array as it stood after round `d`. Myers' backtrack
532    // walks it in reverse to recover the path.
533    let mut trace: Vec<Vec<usize>> = Vec::new();
534    let mut v = vec![0usize; 2 * max + 2];
535    let mut found = None;
536    'outer: for d in 0..=max {
537        trace.push(v.clone());
538        let mut k = -(d as isize);
539        while k <= d as isize {
540            let idx = (k + max as isize) as usize;
541            let mut x = if k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]) {
542                v[idx + 1]
543            } else {
544                v[idx - 1] + 1
545            };
546            let mut y = (x as isize - k) as usize;
547            while x < n && y < m && old[x] == new[y] {
548                x += 1;
549                y += 1;
550            }
551            v[idx] = x;
552            if x >= n && y >= m {
553                found = Some(d);
554                break 'outer;
555            }
556            k += 2;
557        }
558    }
559    let Some(d_end) = found else {
560        // The bound. The trivial script, and it is a real one.
561        let mut out = Vec::new();
562        if n > 0 {
563            out.push(Op::Delete { old: 0, count: n });
564        }
565        if m > 0 {
566            out.push(Op::Insert { new: 0, count: m });
567        }
568        return out;
569    };
570
571    // Backtrack: from (n, m) down to (0, 0), recording the moves in reverse.
572    let mut ops: Vec<Op> = Vec::new();
573    let (mut x, mut y) = (n, m);
574    for d in (1..=d_end).rev() {
575        let v = &trace[d];
576        let k = x as isize - y as isize;
577        let idx = (k + max as isize) as usize;
578        let down = k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]);
579        let prev_k = if down { k + 1 } else { k - 1 };
580        let prev_x = v[(prev_k + max as isize) as usize];
581        let prev_y = (prev_x as isize - prev_k) as usize;
582        // The diagonal run first (it happened last), then the single step.
583        while x > prev_x && y > prev_y {
584            x -= 1;
585            y -= 1;
586            push_run(
587                &mut ops,
588                Op::Equal {
589                    old: x,
590                    new: y,
591                    count: 1,
592                },
593            );
594        }
595        if down {
596            y -= 1;
597            push_run(&mut ops, Op::Insert { new: y, count: 1 });
598        } else {
599            x -= 1;
600            push_run(&mut ops, Op::Delete { old: x, count: 1 });
601        }
602    }
603    while x > 0 && y > 0 {
604        x -= 1;
605        y -= 1;
606        push_run(
607            &mut ops,
608            Op::Equal {
609                old: x,
610                new: y,
611                count: 1,
612            },
613        );
614    }
615    ops.reverse();
616    ops
617}
618
619/// Merge a one-line op into the run being built, backwards.
620fn push_run(ops: &mut Vec<Op>, op: Op) {
621    match (ops.last_mut(), op) {
622        (
623            Some(Op::Equal { old, new, count }),
624            Op::Equal {
625                old: o,
626                new: nw,
627                count: 1,
628            },
629        ) if *old == o + 1 && *new == nw + 1 => {
630            *old = o;
631            *new = nw;
632            *count += 1;
633        }
634        (Some(Op::Delete { old, count }), Op::Delete { old: o, count: 1 }) if *old == o + 1 => {
635            *old = o;
636            *count += 1;
637        }
638        (Some(Op::Insert { new, count }), Op::Insert { new: nw, count: 1 }) if *new == nw + 1 => {
639            *new = nw;
640            *count += 1;
641        }
642        _ => ops.push(op),
643    }
644}
645
646/// Where line `new_line` of the new file came from in the old one, if it was
647/// already there.
648///
649/// `None` means the line is **new in this version** — which is exactly the
650/// answer a blame stops on.
651pub fn trace_line(old: &[&[u8]], new: &[&[u8]], new_line: usize) -> Option<usize> {
652    for op in diff_ops(old, new) {
653        if let Op::Equal { old, new, count } = op {
654            if new_line >= new && new_line < new + count {
655                return Some(old + (new_line - new));
656            }
657        }
658    }
659    None
660}
661
662/// Split a blob into lines, for callers outside this module.
663pub fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
664    lines(bytes)
665}
666
667#[cfg(test)]
668mod tests {
669    use super::{edit_counts, is_binary, lines, MAX_EDIT_DISTANCE};
670
671    fn count(old: &str, new: &str) -> (usize, usize) {
672        let old_lines = lines(old.as_bytes());
673        let new_lines = lines(new.as_bytes());
674        edit_counts(&old_lines, &new_lines)
675    }
676
677    #[test]
678    fn identical_files_have_no_edits() {
679        assert_eq!(count("a\nb\nc\n", "a\nb\nc\n"), (0, 0));
680    }
681
682    #[test]
683    fn one_line_replaced_is_one_in_and_one_out() {
684        // The classic case a "diff" that counts changed *files* gets wrong: a
685        // replacement is an insertion AND a deletion, not one edit.
686        assert_eq!(count("a\nb\nc\n", "a\nB\nc\n"), (1, 1));
687    }
688
689    #[test]
690    fn appended_lines_are_insertions_only() {
691        assert_eq!(count("a\n", "a\nb\nc\n"), (2, 0));
692    }
693
694    #[test]
695    fn removed_lines_are_deletions_only() {
696        assert_eq!(count("a\nb\nc\n", "a\n"), (0, 2));
697    }
698
699    #[test]
700    fn a_line_moved_is_counted_as_one_move_not_two_rewrites() {
701        // `b` moves from the front to the back. The minimal script is one
702        // delete and one insert; a line-by-line comparison would say three of
703        // each, which is the number that makes a review look enormous.
704        assert_eq!(count("b\na\nc\n", "a\nc\nb\n"), (1, 1));
705    }
706
707    #[test]
708    fn an_empty_file_gaining_content_is_all_insertions() {
709        assert_eq!(count("", "a\nb\n"), (2, 0));
710        assert_eq!(count("a\nb\n", ""), (0, 2));
711    }
712
713    #[test]
714    fn a_file_with_no_trailing_newline_has_the_same_line_count() {
715        assert_eq!(lines(b"a\nb").len(), 2);
716        assert_eq!(lines(b"a\nb\n").len(), 2);
717        assert_eq!(lines(b"").len(), 0);
718    }
719
720    /// Past the bound the answer is the trivial script, and the trivial script
721    /// is still an edit script. What must never happen is `(0, 0)`.
722    #[test]
723    fn a_change_past_the_bound_reports_an_upper_bound_and_never_zero() {
724        let old: String = (0..MAX_EDIT_DISTANCE + 100)
725            .map(|i| format!("old {i}\n"))
726            .collect();
727        let new: String = (0..MAX_EDIT_DISTANCE + 100)
728            .map(|i| format!("new {i}\n"))
729            .collect();
730        let (insertions, deletions) = count(&old, &new);
731        assert!(
732            insertions > 0 && deletions > 0,
733            "a wholesale rewrite reported {insertions} insertions and {deletions} deletions; a \
734             zero here is indistinguishable from `nothing changed`"
735        );
736    }
737
738    #[test]
739    fn a_nul_byte_makes_a_file_binary_and_a_high_byte_does_not() {
740        assert!(is_binary(b"\x89PNG\r\n\x1a\n\0\0"));
741        // UTF-8 text is full of bytes above 127 and is not binary.
742        assert!(!is_binary("räksmörgås\n".as_bytes()));
743    }
744}
745
746#[cfg(test)]
747mod script_tests {
748    use super::{diff_ops, lines, trace_line, Op};
749
750    fn split(text: &str) -> Vec<&[u8]> {
751        lines(text.as_bytes())
752    }
753
754    /// The script must **cover** both files: every old line is deleted or
755    /// equal, every new line inserted or equal, each exactly once. A script
756    /// that skips a line is a blame that attributes it to the wrong commit.
757    fn covers(old: &str, new: &str) {
758        let (o, n) = (split(old), split(new));
759        let mut old_seen = vec![0usize; o.len()];
760        let mut new_seen = vec![0usize; n.len()];
761        for op in diff_ops(&o, &n) {
762            match op {
763                Op::Equal { old, new, count } => {
764                    for i in 0..count {
765                        old_seen[old + i] += 1;
766                        new_seen[new + i] += 1;
767                        assert_eq!(
768                            o[old + i],
769                            n[new + i],
770                            "an Equal run names two lines that are not equal"
771                        );
772                    }
773                }
774                Op::Delete { old, count } => {
775                    for i in 0..count {
776                        old_seen[old + i] += 1;
777                    }
778                }
779                Op::Insert { new, count } => {
780                    for i in 0..count {
781                        new_seen[new + i] += 1;
782                    }
783                }
784            }
785        }
786        assert!(
787            old_seen.iter().all(|c| *c == 1),
788            "the script does not cover every old line exactly once: {old_seen:?}"
789        );
790        assert!(
791            new_seen.iter().all(|c| *c == 1),
792            "the script does not cover every new line exactly once: {new_seen:?}"
793        );
794    }
795
796    #[test]
797    fn the_script_covers_both_files_in_every_shape_that_matters() {
798        covers("", "");
799        covers("a\n", "");
800        covers("", "a\n");
801        covers("a\nb\nc\n", "a\nb\nc\n");
802        covers("a\nb\nc\n", "a\nB\nc\n");
803        covers("a\nb\nc\n", "c\nb\na\n");
804        covers("a\nb\nc\nd\ne\n", "a\nc\ne\n");
805        covers("x\n", "a\nb\nc\nd\n");
806        covers(
807            &(0..40).map(|i| format!("l{i}\n")).collect::<String>(),
808            &(0..40)
809                .map(|i| {
810                    if i % 7 == 0 {
811                        format!("L{i}\n")
812                    } else {
813                        format!("l{i}\n")
814                    }
815                })
816                .collect::<String>(),
817        );
818    }
819
820    /// **A line that survived is traced back; a line that is new is not.**
821    /// Both halves are the blame's stopping rule.
822    #[test]
823    fn a_surviving_line_traces_back_and_a_new_line_does_not() {
824        let old = split("alpha\nbeta\ngamma\n");
825        let new = split("alpha\nINSERTED\nbeta\ngamma\n");
826
827        assert_eq!(trace_line(&old, &new, 0), Some(0), "`alpha` did not move");
828        assert_eq!(
829            trace_line(&old, &new, 1),
830            None,
831            "the inserted line must have no ancestor — that is where a blame stops"
832        );
833        assert_eq!(
834            trace_line(&old, &new, 2),
835            Some(1),
836            "`beta` moved down one and must trace back to where it was"
837        );
838        assert_eq!(trace_line(&old, &new, 3), Some(2));
839    }
840
841    /// A rewritten line is new, even though its neighbours survived.
842    #[test]
843    fn a_changed_line_has_no_ancestor() {
844        let old = split("keep\nold value\nkeep\n");
845        let new = split("keep\nnew value\nkeep\n");
846        assert_eq!(trace_line(&old, &new, 0), Some(0));
847        assert_eq!(
848            trace_line(&old, &new, 1),
849            None,
850            "the changed line must be attributed to the commit that changed it"
851        );
852        assert_eq!(trace_line(&old, &new, 2), Some(2));
853    }
854}