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}