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 mode, 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}