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