Skip to main content

oxicode/tui_vt/git_tui/
diff_doc.rs

1//! Unified-diff parsing + whitespace/formatting filters.
2//!
3//! Pure data model — no terminal I/O, no ratatui rendering. Consumed by the
4//! git TUI overlay (rendering wired in a follow-up task).
5
6// ---------------------------------------------------------------------------
7// Types
8// ---------------------------------------------------------------------------
9
10/// One parsed unified-diff document: zero or more [`DiffFile`] entries.
11#[derive(Debug, Default, Clone, PartialEq, Eq)]
12pub struct DiffDocument {
13    /// Files in the order they appeared in the input.
14    pub files: Vec<DiffFile>,
15}
16
17/// One file in a [`DiffDocument`].
18#[derive(Debug, Default, Clone, PartialEq, Eq)]
19pub struct DiffFile {
20    /// Path on the `b/` side of the diff (the post-image).
21    pub path: String,
22    /// Path on the `a/` side (the pre-image). Set on renames.
23    pub old_path: Option<String>,
24    /// Hunks in source order.
25    pub hunks: Vec<Hunk>,
26    /// `true` when the file was reported as binary (`Binary files ... differ`).
27    /// Binary files never carry hunks.
28    pub binary: bool,
29}
30
31/// One hunk header (`@@ -old,count +new,count @@`) plus its lines.
32#[derive(Debug, Default, Clone, PartialEq, Eq)]
33pub struct Hunk {
34    /// 1-based starting line in the pre-image (or 0 when count is 0).
35    pub old_start: u32,
36    /// 1-based starting line in the post-image (or 0 when count is 0).
37    pub new_start: u32,
38    /// Lines in source order. Context first, then added, then removed, as
39    /// produced by `git diff`. A filter may mutate kinds in place.
40    pub lines: Vec<DiffLine>,
41}
42
43/// One line inside a [`Hunk`]. The text DOES NOT include the leading
44/// `+`/`-`/` ` prefix character.
45#[derive(Debug, Default, Clone, PartialEq, Eq)]
46pub struct DiffLine {
47    /// Line role inside the hunk.
48    pub kind: DiffLineKind,
49    /// Line body without the diff prefix character.
50    pub text: String,
51}
52
53/// Role of a single [`DiffLine`].
54#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum DiffLineKind {
56    /// Unchanged context (` ` prefix).
57    #[default]
58    Context,
59    /// Insertion (`+` prefix).
60    Added,
61    /// Deletion (`-` prefix).
62    Removed,
63}
64
65/// Whitespace / formatting filter mode for [`filter_whitespace`].
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum WhitespaceMode {
68    /// No filtering — return the document unchanged.
69    #[default]
70    Off,
71    /// Demote hunks whose only changes are whitespace-only added/removed lines.
72    IgnoreWhitespace,
73    /// Additionally demote hunks that are only formatting changes (indent,
74    /// blank lines, language-specific import-only hunks).
75    IgnoreFormatting,
76}
77
78/// View mode for the rendered diff. Orthogonal to [`WhitespaceMode`] — both
79/// live on the same [`DiffDocument`] and can be applied independently.
80#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
81pub enum DiffViewMode {
82    /// Two-column side-by-side.
83    Split,
84    /// Unified inline (default).
85    #[default]
86    Inline,
87    /// Hunk list only.
88    Hunks,
89    /// File list only.
90    Files,
91}
92
93// ---------------------------------------------------------------------------
94// Public API
95// ---------------------------------------------------------------------------
96
97/// Parse a `git diff` output into a [`DiffDocument`].
98///
99/// Handles:
100/// * `diff --git a/X b/Y` file headers
101/// * `rename from X` / `rename to Y` — populates [`DiffFile::old_path`]
102/// * `similarity index N%` — currently skipped (recorded but not exposed)
103/// * `new file mode ...` / `deleted file mode ...` — skipped
104/// * `Binary files X and Y differ` — sets [`DiffFile::binary`] and emits no
105///   hunks
106/// * `index abc..def 100644` — skipped
107/// * `--- a/X` / `+++ b/Y` — consumed as part of the file header
108/// * `@@ -old,count +new,count @@` — hunk headers
109/// * context / `+` / `-` lines
110///
111/// Multiple files are supported and emitted in source order.
112pub fn parse_unified_diff(input: &str) -> DiffDocument {
113    let mut doc = DiffDocument::default();
114    let mut iter = input.lines().peekable();
115    while let Some(line) = iter.next() {
116        if let Some(rest) = line.strip_prefix("diff --git ")
117            && let Some(file) = parse_one_file(&mut iter, rest)
118        {
119            doc.files.push(file);
120        }
121        // Anything outside a `diff --git` block is dropped silently —
122        // unified diffs do not carry top-level data outside file headers.
123    }
124
125    doc
126}
127
128/// Filter a [`DiffDocument`] by [`WhitespaceMode`]. Returns a new document;
129/// hunks that survive filtering keep their text but may have their line
130/// kinds rewritten (Added/Removed → Context). Hunks that are fully demoted
131/// keep all their lines — only the kind changes.
132pub fn filter_whitespace(doc: &DiffDocument, mode: WhitespaceMode) -> DiffDocument {
133    if matches!(mode, WhitespaceMode::Off) {
134        return doc.clone();
135    }
136
137    let mut out = DiffDocument::default();
138    for file in &doc.files {
139        let mut new_file = DiffFile {
140            path: file.path.clone(),
141            old_path: file.old_path.clone(),
142            binary: file.binary,
143            hunks: Vec::with_capacity(file.hunks.len()),
144        };
145
146        for hunk in &file.hunks {
147            if hunk_should_demote(hunk, file.path.as_str(), mode) {
148                let demoted: Vec<DiffLine> = hunk
149                    .lines
150                    .iter()
151                    .map(|l| DiffLine {
152                        kind: DiffLineKind::Context,
153                        text: l.text.clone(),
154                    })
155                    .collect();
156                new_file.hunks.push(Hunk {
157                    old_start: hunk.old_start,
158                    new_start: hunk.new_start,
159                    lines: demoted,
160                });
161            } else {
162                new_file.hunks.push(hunk.clone());
163            }
164        }
165
166        out.files.push(new_file);
167    }
168
169    out
170}
171
172// ---------------------------------------------------------------------------
173// Implementation: per-file parsing
174// ---------------------------------------------------------------------------
175
176fn parse_one_file<'a, I: Iterator<Item = &'a str>>(
177    iter: &mut std::iter::Peekable<I>,
178    header_rest: &str,
179) -> Option<DiffFile> {
180    // header_rest = "a/X b/Y" — pull the b-side path (a-side captured for
181    // symmetry but currently unused).
182    let (_a_path, b_path) = parse_diff_git_paths(header_rest);
183    let mut file = DiffFile {
184        path: b_path,
185        old_path: None,
186        hunks: Vec::new(),
187        binary: false,
188    };
189
190    // Consume subsequent header lines until we reach the first hunk header
191    // or the next `diff --git` / EOF.
192    let mut pending_rename_from: Option<String> = None;
193
194    loop {
195        match iter.peek().copied() {
196            None => return Some(file),
197            Some(next) if next.starts_with("diff --git ") => return Some(file),
198            Some(next) if next.starts_with("Binary files ") && next.contains(" differ") => {
199                // "Binary files a/X and b/Y differ"
200                file.binary = true;
201                iter.next();
202                // No hunks for binary files.
203                continue;
204            }
205            Some(next) if next.starts_with("rename from ") => {
206                let p = next.trim_start_matches("rename from ").to_string();
207                pending_rename_from = Some(p);
208                iter.next();
209                continue;
210            }
211            Some(next) if next.starts_with("rename to ") => {
212                let p = next.trim_start_matches("rename to ").to_string();
213                // Old path wins from the `rename from` line; if absent (some
214                // emitters only emit `rename to`), leave old_path None.
215                if let Some(from) = pending_rename_from.take() {
216                    file.old_path = Some(from);
217                } else {
218                    file.old_path = Some(p.clone());
219                }
220                // The b-side path also updates on rename; git already updated
221                // the `diff --git` line, but defensively trust the latest.
222                file.path = p;
223                iter.next();
224                continue;
225            }
226            Some(next) if next.starts_with("new file mode") => {
227                iter.next();
228                continue;
229            }
230            Some(next) if next.starts_with("deleted file mode") => {
231                iter.next();
232                continue;
233            }
234            Some(next) if next.starts_with("similarity index") => {
235                iter.next();
236                continue;
237            }
238            Some(next) if next.starts_with("index ") => {
239                iter.next();
240                continue;
241            }
242            Some(next) if next.starts_with("--- ") || next.starts_with("+++ ") => {
243                iter.next();
244                continue;
245            }
246            Some(next) if next.starts_with("@@ ") => {
247                // Start of hunks for this file.
248                file.hunks = parse_hunks(iter);
249                return Some(file);
250            }
251            Some(_) => {
252                // Unknown header line — skip and keep going.
253                iter.next();
254            }
255        }
256    }
257}
258
259fn parse_diff_git_paths(rest: &str) -> (String, String) {
260    // "a/X b/Y" — split on the first space and strip the leading "a/"/"b/".
261    let mut parts = rest.splitn(2, ' ');
262    let a_raw = parts.next().unwrap_or("");
263    let b_raw = parts.next().unwrap_or("");
264    (strip_prefix_path(a_raw), strip_prefix_path(b_raw))
265}
266
267fn strip_prefix_path(p: &str) -> String {
268    if let Some(stripped) = p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")) {
269        stripped.to_string()
270    } else {
271        p.to_string()
272    }
273}
274
275fn parse_hunks<'a, I: Iterator<Item = &'a str>>(iter: &mut std::iter::Peekable<I>) -> Vec<Hunk> {
276    let mut hunks = Vec::new();
277    while let Some(line) = iter.peek().copied() {
278        if !line.starts_with("@@ ") {
279            break;
280        }
281        let header = line;
282        iter.next();
283        let Some((old_start, new_start)) = parse_hunk_header(header) else {
284            // Malformed header — bail out of this file's hunks.
285            break;
286        };
287
288        let mut hunk = Hunk {
289            old_start,
290            new_start,
291            lines: Vec::new(),
292        };
293
294        // After a hunk header, consume lines until we hit another header.
295        while let Some(body) = iter.peek().copied() {
296            if body.starts_with("@@ ")
297                || body.starts_with("diff --git ")
298                || body.starts_with("Binary files ")
299            {
300                break;
301            }
302            // --- / +++ markers between hunks (rare but legal) — skip.
303            if body.starts_with("--- ") || body.starts_with("+++ ") {
304                iter.next();
305                continue;
306            }
307            iter.next();
308            let Some(parsed) = parse_diff_body_line(body) else {
309                continue;
310            };
311            hunk.lines.push(parsed);
312        }
313
314        hunks.push(hunk);
315    }
316
317    hunks
318}
319
320fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
321    // "@@ -old,count +new,count @@ optional section heading"
322
323    let after_at = line.strip_prefix("@@ ")?;
324    let middle = after_at.split(" @@ ").next()?;
325    let mut sides = middle.split(' ');
326    let old_part = sides.next()?;
327    let new_part = sides.next()?;
328    Some((parse_side_start(old_part)?, parse_side_start(new_part)?))
329}
330
331fn parse_side_start(part: &str) -> Option<u32> {
332    // "-N,M" or "+N,M" or "-N" or "+N" — drop the leading -/+, drop ",count".
333    let trimmed = part.trim_start_matches('-').trim_start_matches('+');
334    let count_or_start = trimmed.split(',').next()?;
335    if count_or_start.is_empty() {
336        Some(0)
337    } else {
338        count_or_start.parse::<u32>().ok()
339    }
340}
341
342fn parse_diff_body_line(line: &str) -> Option<DiffLine> {
343    let mut chars = line.chars();
344    let prefix = chars.next()?;
345    let kind = match prefix {
346        '+' => DiffLineKind::Added,
347        '-' => DiffLineKind::Removed,
348        ' ' => DiffLineKind::Context,
349        // "\ No newline at end of file" — skip.
350        '\\' => return None,
351        _ => return None,
352    };
353    Some(DiffLine {
354        kind,
355        text: chars.collect::<String>(),
356    })
357}
358
359// ---------------------------------------------------------------------------
360// Implementation: whitespace / formatting filter
361// ---------------------------------------------------------------------------
362
363fn hunk_should_demote(hunk: &Hunk, path: &str, mode: WhitespaceMode) -> bool {
364    // A hunk qualifies for demotion when EVERY non-context change
365    // (Added/Removed) is one of the allowed kinds for this mode.
366    // If the hunk has no changes at all (context-only), we leave it alone —
367    // there's nothing to demote, and rewriting kinds would be a no-op.
368    let has_any_change = hunk
369        .lines
370        .iter()
371        .any(|l| !matches!(l.kind, DiffLineKind::Context));
372    if !has_any_change {
373        return false;
374    }
375
376    // IgnoreWhitespace only needs the per-line whitespace check; the indent
377    // and import passes require pre-computed stripped bodies, so we defer
378    // the allocation until the formatting mode actually needs them.
379    let stripped = (mode == WhitespaceMode::IgnoreFormatting).then(|| {
380        let mut added = Vec::new();
381        let mut removed = Vec::new();
382        for l in &hunk.lines {
383            match l.kind {
384                DiffLineKind::Context => {}
385                DiffLineKind::Added => added.push(l.text.trim_start().to_string()),
386                DiffLineKind::Removed => removed.push(l.text.trim_start().to_string()),
387            }
388        }
389        (added, removed)
390    });
391
392    hunk.lines.iter().all(|l| match l.kind {
393        DiffLineKind::Context => true,
394        DiffLineKind::Added | DiffLineKind::Removed => {
395            // An empty line is "whitespace-only" by definition.
396            if line_is_whitespace_only(&l.text) {
397                return true;
398            }
399            if mode == WhitespaceMode::IgnoreFormatting
400                && let Some((added, removed)) = stripped.as_ref()
401            {
402                if line_is_indent_only(&l.text, added, removed) {
403                    return true;
404                }
405                if line_is_import_only(&l.text, path) {
406                    return true;
407                }
408            }
409            false
410        }
411    })
412}
413
414fn line_is_whitespace_only(s: &str) -> bool {
415    s.chars().all(|c| c.is_whitespace())
416}
417
418/// Indent-only when the line's leading-whitespace-stripped body matches an
419/// opposite-side line in the same hunk.
420fn line_is_indent_only(text: &str, added: &[String], removed: &[String]) -> bool {
421    let stripped = text.trim_start();
422    if stripped.is_empty() {
423        return false; // already covered by whitespace-only
424    }
425    added.iter().any(|s| s == stripped) && removed.iter().any(|s| s == stripped)
426}
427
428fn line_is_import_only(text: &str, path: &str) -> bool {
429    let ext = path.rsplit('.').next().unwrap_or("");
430    let t = text.trim_start();
431    match ext {
432        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
433            t.starts_with("import ") || (t.starts_with("export ") && t.contains(" from "))
434        }
435        "rs" => t.starts_with("use "),
436        "go" => t.starts_with("import "),
437        _ => false,
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Tests (TDD — written first, exercised before implementation was filled in)
443// ---------------------------------------------------------------------------
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    // -- Fixtures ---------------------------------------------------------
450
451    const TWO_FILES: &str = "\
452diff --git a/foo.txt b/foo.txt
453index 1234567..89abcdef 100644
454--- a/foo.txt
455+++ b/foo.txt
456@@ -1,3 +1,4 @@
457 line one
458+inserted
459 line two
460 line three
461@@ -10,2 +11,3 @@
462 line ten
463-removed
464+added
465+another
466diff --git a/bar.txt b/bar.txt
467index 1111111..2222222 100644
468--- a/bar.txt
469+++ b/bar.txt
470@@ -1,1 +1,2 @@
471 head
472+tail
473";
474
475    const RENAME_AND_BINARY: &str = "\
476diff --git a/old/name.txt b/new/name.txt
477similarity index 95%
478rename from old/name.txt
479rename to new/name.txt
480index abc..def 100644
481--- a/old/name.txt
482+++ b/new/name.txt
483@@ -1,1 +1,1 @@
484-same
485+same
486diff --git a/img.png b/img.png
487index 111..222 100644
488Binary files a/img.png and b/img.png differ
489";
490
491    // Whitespace-only hunk fixture.
492    //
493    // Normalized from the brief's literal embedded-escapes form
494    // (`-\"\"` → -"" / `+  \"` → +  ") to unambiguous UTF-8 payload
495    // (`-` removes an empty line / `+  ` adds a line with two spaces).
496    // The intent — pure whitespace/blank changes in hunk A, real code in
497    // hunk B — is preserved; the original quoting was brittle and easy to
498    // misread in the source.
499    const WHITESPACE_FIXTURE: &str = "\
500diff --git a/ws.txt b/ws.txt
501--- a/ws.txt
502+++ b/ws.txt
503@@ -1,2 +1,2 @@
504 context
505-
506+  
507@@ -10,2 +10,2 @@
508 context
509-real
510+RIPPED
511";
512
513    const FORMATTING_FIXTURE: &str = "\
514diff --git a/a.ts b/a.ts
515--- a/a.ts
516+++ b/a.ts
517@@ -1,3 +1,3 @@
518 import { a } from 'a';
519 import { b } from 'b';
520-import { c } from 'c';
521+import { z } from 'z';
522diff --git a/b.rs b/b.rs
523--- a/b.rs
524+++ b/b.rs
525@@ -1,3 +1,3 @@
526 fn f() {
527-    let x = 1;
528+        let x = 1;
529 }
530diff --git a/c.go b/c.go
531--- a/c.go
532+++ b/c.go
533@@ -1,3 +1,3 @@
534 package x
535-func old() {}
536+func NEW() {}
537";
538
539    // -- Tests ------------------------------------------------------------
540
541    #[test]
542    fn parse_unified_diff_basic() {
543        let doc = parse_unified_diff(TWO_FILES);
544        assert_eq!(doc.files.len(), 2, "expected 2 files");
545
546        let foo = &doc.files[0];
547        assert_eq!(foo.path, "foo.txt");
548        assert!(foo.old_path.is_none());
549        assert!(!foo.binary);
550        assert_eq!(foo.hunks.len(), 2);
551
552        let h1 = &foo.hunks[0];
553        assert_eq!(h1.old_start, 1);
554        assert_eq!(h1.new_start, 1);
555        assert_eq!(h1.lines.len(), 4);
556        assert_eq!(h1.lines[0].kind, DiffLineKind::Context);
557        assert_eq!(h1.lines[0].text, "line one");
558        assert_eq!(h1.lines[1].kind, DiffLineKind::Added);
559        assert_eq!(h1.lines[1].text, "inserted");
560        assert_eq!(h1.lines[2].kind, DiffLineKind::Context);
561        assert_eq!(h1.lines[2].text, "line two");
562        assert_eq!(h1.lines[3].kind, DiffLineKind::Context);
563        assert_eq!(h1.lines[3].text, "line three");
564
565        let h2 = &foo.hunks[1];
566        assert_eq!(h2.old_start, 10);
567        assert_eq!(h2.new_start, 11);
568        assert_eq!(h2.lines.len(), 4);
569        assert_eq!(h2.lines[0].kind, DiffLineKind::Context);
570        assert_eq!(h2.lines[0].text, "line ten");
571        assert_eq!(h2.lines[1].kind, DiffLineKind::Removed);
572        assert_eq!(h2.lines[1].text, "removed");
573        assert_eq!(h2.lines[2].kind, DiffLineKind::Added);
574        assert_eq!(h2.lines[2].text, "added");
575        assert_eq!(h2.lines[3].kind, DiffLineKind::Added);
576        assert_eq!(h2.lines[3].text, "another");
577
578        let bar = &doc.files[1];
579        assert_eq!(bar.path, "bar.txt");
580        assert_eq!(bar.hunks.len(), 1);
581        assert_eq!(bar.hunks[0].old_start, 1);
582        assert_eq!(bar.hunks[0].new_start, 1);
583        assert_eq!(bar.hunks[0].lines.len(), 2);
584        assert_eq!(bar.hunks[0].lines[1].kind, DiffLineKind::Added);
585        assert_eq!(bar.hunks[0].lines[1].text, "tail");
586    }
587
588    #[test]
589    fn hunk_boundaries_correct() {
590        let doc = parse_unified_diff(TWO_FILES);
591        let foo = &doc.files[0];
592
593        assert_eq!(foo.hunks[0].old_start, 1);
594        assert_eq!(foo.hunks[0].new_start, 1);
595        assert_eq!(foo.hunks[1].old_start, 10);
596        assert_eq!(foo.hunks[1].new_start, 11);
597
598        // No context line straddles: last of hunk 1 is "line three",
599        // first of hunk 2 is "line ten" — they must NOT merge.
600        assert_eq!(foo.hunks[0].lines.last().unwrap().text, "line three");
601        assert_eq!(foo.hunks[1].lines.first().unwrap().text, "line ten");
602        assert_eq!(foo.hunks[0].lines.len(), 4);
603        assert_eq!(foo.hunks[1].lines.len(), 4);
604    }
605
606    #[test]
607    fn rename_and_binary_files_parsed() {
608        let doc = parse_unified_diff(RENAME_AND_BINARY);
609        assert_eq!(doc.files.len(), 2);
610
611        let renamed = &doc.files[0];
612        assert_eq!(renamed.path, "new/name.txt");
613        assert_eq!(renamed.old_path.as_deref(), Some("old/name.txt"));
614        assert!(!renamed.binary);
615        assert_eq!(renamed.hunks.len(), 1);
616
617        let binary = &doc.files[1];
618        assert_eq!(binary.path, "img.png");
619        assert!(binary.binary);
620        assert!(binary.hunks.is_empty());
621    }
622
623    #[test]
624    fn ignore_whitespace_drops_ws_only_hunks() {
625        let doc = parse_unified_diff(WHITESPACE_FIXTURE);
626        let filtered = filter_whitespace(&doc, WhitespaceMode::IgnoreWhitespace);
627
628        let file = &filtered.files[0];
629        assert_eq!(file.hunks.len(), 2);
630
631        // Hunk 1: removed empty + added "  " — both whitespace-only — demoted.
632        let h1 = &file.hunks[0];
633        assert!(
634            h1.lines
635                .iter()
636                .all(|l| matches!(l.kind, DiffLineKind::Context))
637        );
638        // Texts preserved.
639        assert_eq!(h1.lines[1].text, "");
640        assert_eq!(h1.lines[2].text, "  ");
641
642        // Hunk 2: real code change — NOT demoted.
643        let h2 = &file.hunks[1];
644        assert_eq!(h2.lines[1].kind, DiffLineKind::Removed);
645        assert_eq!(h2.lines[1].text, "real");
646        assert_eq!(h2.lines[2].kind, DiffLineKind::Added);
647        assert_eq!(h2.lines[2].text, "RIPPED");
648    }
649
650    #[test]
651    fn ignore_formatting_drops_import_and_indent_hunks() {
652        let doc = parse_unified_diff(FORMATTING_FIXTURE);
653        let filtered = filter_whitespace(&doc, WhitespaceMode::IgnoreFormatting);
654        assert_eq!(filtered.files.len(), 3);
655
656        // TS: pure import reorder — must demote.
657        let ts = &filtered.files[0];
658        assert_eq!(ts.path, "a.ts");
659        assert!(
660            ts.hunks[0]
661                .lines
662                .iter()
663                .all(|l| matches!(l.kind, DiffLineKind::Context))
664        );
665
666        // Rust: pure indent change — must demote.
667        let rs = &filtered.files[1];
668        assert_eq!(rs.path, "b.rs");
669        assert!(
670            rs.hunks[0]
671                .lines
672                .iter()
673                .all(|l| matches!(l.kind, DiffLineKind::Context))
674        );
675
676        // Go: real change — must survive.
677        let go = &filtered.files[2];
678        assert_eq!(go.path, "c.go");
679        assert_eq!(go.hunks[0].lines[1].kind, DiffLineKind::Removed);
680        assert_eq!(go.hunks[0].lines[1].text, "func old() {}");
681        assert_eq!(go.hunks[0].lines[2].kind, DiffLineKind::Added);
682        assert_eq!(go.hunks[0].lines[2].text, "func NEW() {}");
683    }
684
685    #[test]
686    fn view_mode_is_orthogonal_to_filter() {
687        let doc = parse_unified_diff(TWO_FILES);
688        for mode in [
689            DiffViewMode::Split,
690            DiffViewMode::Inline,
691            DiffViewMode::Hunks,
692            DiffViewMode::Files,
693        ] {
694            let m2 = mode;
695            assert_eq!(mode, m2);
696        }
697        let filtered = filter_whitespace(&doc, WhitespaceMode::Off);
698        assert_eq!(filtered, doc, "Off mode must return an identical document");
699    }
700}