Skip to main content

tokenfold_core/transforms/
diff.rs

1//! `diff_compaction` (canonical id: `"diff_compaction"`, v1.0.0).
2//!
3//! Lossy-with-evidence transform operating on unified-diff text such as the output of
4//! `git diff`. Ships behind `--experimental` until the fidelity gate is green for this
5//! transform (see F-013 / F-016 in roadmap.md).
6//!
7//! This module is not yet wired into the crate (no `mod transforms;` declaration exists in
8//! `lib.rs`); it implements the mechanical line-classification behavior only. The policy
9//! decision of *when* the header-only form (`keep_line_bodies = false`) is allowed to run
10//! (only for `TaskScope::ChangeSummary`) is made by the caller, not by this module.
11
12/// Stable canonical transform id, for future `TransformReport.id` wiring.
13pub const TRANSFORM_ID: &str = "diff_compaction";
14/// Semantic version of this transform's behavior, for future `TransformReport.version` wiring.
15pub const TRANSFORM_VERSION: &str = "1.0.0";
16
17/// How a single line of unified-diff input is classified by [`compact_diff`].
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum LineKind {
20    /// `diff --git ...`, `index ...`, `--- `, `+++ `, or `@@...` hunk header lines.
21    /// Always kept verbatim, in order, regardless of `keep_line_bodies`.
22    Structural,
23    /// A changed line body: starts with `+` or `-` (but not the structural `--- `/`+++ `
24    /// forms). Kept verbatim, in order, only when `keep_line_bodies` is `true`.
25    ChangeBody,
26    /// Everything else: an unchanged context line. Never kept in the output.
27    Context,
28}
29
30/// Classifies a single line of unified-diff text. See [`LineKind`] for the rules.
31fn classify_line(line: &str) -> LineKind {
32    if line.starts_with("diff --git")
33        || line.starts_with("index ")
34        || line.starts_with("--- ")
35        || line.starts_with("+++ ")
36        || line.starts_with("@@")
37    {
38        LineKind::Structural
39    } else if line.starts_with('+') || line.starts_with('-') {
40        LineKind::ChangeBody
41    } else {
42        LineKind::Context
43    }
44}
45
46/// Pushes a `[N <noun> dropped]` evidence marker onto `out` if any lines have been dropped
47/// since the last flush, then resets the run counter. No-op when nothing was dropped.
48fn flush_dropped_run(out: &mut Vec<String>, dropped_run: &mut usize, marker_noun: &str) {
49    if *dropped_run > 0 {
50        out.push(format!("[{dropped_run} {marker_noun} dropped]"));
51        *dropped_run = 0;
52    }
53}
54
55/// Compacts unified-diff text (e.g. `git diff` output) per the `diff_compaction` (F-013)
56/// contract.
57///
58/// Processes `input` line by line (via [`str::lines`]) and classifies each line:
59///
60/// - **Structural** lines — `diff --git ...`, the git blob-hash `index ...` line, `--- `,
61///   `+++ `, or `@@` hunk headers (e.g. `@@ -12,5 +12,7 @@ optional context`) — are always
62///   kept verbatim, in order, no matter what.
63/// - **Change-body** lines — lines starting with `+` or `-` that are not one of the
64///   structural `--- `/`+++ ` forms — are kept verbatim, in order, only when
65///   `keep_line_bodies` is `true`.
66/// - Every other line is a **context** line (typically starting with a leading space) and is
67///   never kept.
68///
69/// Lines that are dropped (context lines always; change-body lines too when
70/// `keep_line_bodies` is `false`) collapse consecutive runs into a single evidence marker
71/// line, so the marker is emitted once per run of dropped lines rather than once per line:
72///
73/// - `keep_line_bodies == true`: only context lines can ever be dropped in this mode, so the
74///   marker reads `"[N context lines dropped]"`.
75/// - `keep_line_bodies == false` (the header-only form — valid only when the caller's
76///   `TaskScope` is `ChangeSummary`; that policy decision is made by the caller, not here):
77///   both context lines and change-body lines are dropped, so the marker reads
78///   `"[N lines dropped]"` to make clear it covers both.
79///
80/// Relative order of everything that survives is preserved. Empty input produces empty
81/// output. Output lines are joined with `"\n"` with no trailing newline.
82pub fn compact_diff(input: &str, keep_line_bodies: bool) -> String {
83    let marker_noun = if keep_line_bodies {
84        "context lines"
85    } else {
86        "lines"
87    };
88
89    let mut out: Vec<String> = Vec::new();
90    let mut dropped_run: usize = 0;
91
92    for line in input.lines() {
93        match classify_line(line) {
94            LineKind::Structural => {
95                flush_dropped_run(&mut out, &mut dropped_run, marker_noun);
96                out.push(line.to_string());
97            }
98            LineKind::ChangeBody => {
99                if keep_line_bodies {
100                    flush_dropped_run(&mut out, &mut dropped_run, marker_noun);
101                    out.push(line.to_string());
102                } else {
103                    dropped_run += 1;
104                }
105            }
106            LineKind::Context => {
107                dropped_run += 1;
108            }
109        }
110    }
111    flush_dropped_run(&mut out, &mut dropped_run, marker_noun);
112
113    out.join("\n")
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    /// A small realistic unified diff for one file: header lines, 3 leading context lines,
121    /// one removed line, one added line, then 3 trailing context lines. Matches
122    /// `tests/golden/diff_compaction/small_diff.in.txt`.
123    const SAMPLE_DIFF: &str = "diff --git a/f.rs b/f.rs\n\
124        index 1234567..89abcde 100644\n\
125        --- a/f.rs\n\
126        +++ b/f.rs\n\
127        @@ -1,7 +1,7 @@\n\
128        \x20fn main() {\n\
129        \x20    let x = 1;\n\
130        \x20    let y = 2;\n\
131        -    println!(\"{}\", x);\n\
132        +    println!(\"{} {}\", x, y);\n\
133        \x20    let z = 3;\n\
134        \x20    let w = 4;\n\
135        \x20    println!(\"done\");";
136
137    #[test]
138    fn hunk_headers_are_preserved() {
139        let out = compact_diff(SAMPLE_DIFF, true);
140        assert!(out.lines().any(|l| l == "@@ -1,7 +1,7 @@"));
141    }
142
143    #[test]
144    fn change_body_lines_are_preserved_when_keep_line_bodies_true() {
145        let out = compact_diff(SAMPLE_DIFF, true);
146        assert!(out.lines().any(|l| l == "-    println!(\"{}\", x);"));
147        assert!(out.lines().any(|l| l == "+    println!(\"{} {}\", x, y);"));
148    }
149
150    #[test]
151    fn file_names_and_diff_git_header_are_preserved() {
152        let out = compact_diff(SAMPLE_DIFF, true);
153        let lines: Vec<&str> = out.lines().collect();
154        assert!(lines.contains(&"diff --git a/f.rs b/f.rs"));
155        assert!(lines.contains(&"index 1234567..89abcde 100644"));
156        assert!(lines.contains(&"--- a/f.rs"));
157        assert!(lines.contains(&"+++ b/f.rs"));
158    }
159
160    #[test]
161    fn change_body_lines_are_dropped_when_keep_line_bodies_false_but_structural_survives() {
162        let out = compact_diff(SAMPLE_DIFF, false);
163        let lines: Vec<&str> = out.lines().collect();
164
165        // Structural lines still survive.
166        assert!(lines.contains(&"diff --git a/f.rs b/f.rs"));
167        assert!(lines.contains(&"index 1234567..89abcde 100644"));
168        assert!(lines.contains(&"--- a/f.rs"));
169        assert!(lines.contains(&"+++ b/f.rs"));
170        assert!(lines.contains(&"@@ -1,7 +1,7 @@"));
171
172        // Change-body lines are gone, replaced by a "[N lines dropped]"-style marker.
173        // (The structural "+++ b/f.rs" header also starts with '+' and must survive, so the
174        // checks below exclude the structural "--- "/"+++ " forms explicitly.)
175        assert!(
176            !lines
177                .iter()
178                .any(|l| l.starts_with('+') && !l.starts_with("+++ "))
179        );
180        assert!(
181            !lines
182                .iter()
183                .any(|l| l.starts_with('-') && !l.starts_with("--- "))
184        );
185        assert!(lines.iter().any(|l| l.ends_with("lines dropped]")));
186    }
187
188    #[test]
189    fn evidence_marker_counts_consecutive_dropped_context_lines() {
190        let out = compact_diff(SAMPLE_DIFF, true);
191        let lines: Vec<&str> = out.lines().collect();
192
193        // Exactly two markers (one per 3-line context run), each reporting 3, not one
194        // marker per dropped line.
195        let markers: Vec<&&str> = lines.iter().filter(|l| l.ends_with("dropped]")).collect();
196        assert_eq!(markers.len(), 2);
197        for marker in markers {
198            assert_eq!(*marker, "[3 context lines dropped]");
199        }
200    }
201
202    #[test]
203    fn relative_ordering_of_surviving_lines_matches_input() {
204        let out = compact_diff(SAMPLE_DIFF, true);
205        let lines: Vec<&str> = out.lines().collect();
206        assert_eq!(
207            lines,
208            vec![
209                "diff --git a/f.rs b/f.rs",
210                "index 1234567..89abcde 100644",
211                "--- a/f.rs",
212                "+++ b/f.rs",
213                "@@ -1,7 +1,7 @@",
214                "[3 context lines dropped]",
215                "-    println!(\"{}\", x);",
216                "+    println!(\"{} {}\", x, y);",
217                "[3 context lines dropped]",
218            ]
219        );
220    }
221
222    #[test]
223    fn empty_input_returns_empty_output() {
224        assert_eq!(compact_diff("", true), "");
225        assert_eq!(compact_diff("", false), "");
226    }
227
228    #[test]
229    fn pure_context_input_collapses_to_a_single_marker() {
230        let input = " line one\n line two\n line three";
231        let out = compact_diff(input, true);
232        assert_eq!(out, "[3 context lines dropped]");
233    }
234
235    #[test]
236    fn header_only_form_marker_wording_covers_dropped_bodies() {
237        let out = compact_diff(SAMPLE_DIFF, false);
238        // The two context runs (3 each) plus the one removed + one added change-body line
239        // in between collapse into a single 8-line run once bodies are dropped too.
240        assert_eq!(
241            out,
242            "diff --git a/f.rs b/f.rs\n\
243             index 1234567..89abcde 100644\n\
244             --- a/f.rs\n\
245             +++ b/f.rs\n\
246             @@ -1,7 +1,7 @@\n\
247             [8 lines dropped]"
248        );
249    }
250}