Skip to main content

aft/hashline/apply/
edits.rs

1//! Low-level line edits produced by lowering PUT/CUT operations.
2//!
3//! Replacement payloads are tagged so repair layers can distinguish a true
4//! replacement group (inserts + matching deletes) from ordinary gap inserts.
5
6use crate::hashline::scan::TerminatorKind;
7
8/// One concrete edit against pre-request line coordinates.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub enum LineEdit {
11    Insert {
12        /// 1-based anchor line. For BOF inserts this is 1 with [`InsertPlace::Before`].
13        anchor: usize,
14        place: InsertPlace,
15        text: String,
16        mode: InsertMode,
17        /// Source operation index inside the section, used to group replacements.
18        op_index: usize,
19    },
20    Delete {
21        line: usize,
22        op_index: usize,
23    },
24}
25
26#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
27pub enum InsertPlace {
28    Before,
29    After,
30    /// Insert before the first line of the file, even when the file is empty.
31    Bof,
32    /// Append after the last retained line.
33    Eof,
34}
35
36#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
37pub enum InsertMode {
38    Plain,
39    Replacement,
40}
41
42/// A replacement group: contiguous replacement inserts sharing one op, followed
43/// by contiguous deletes for the same op. Mirrors the oracle's lowered form of
44/// `PUT N-M:` with a body.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ReplacementGroup {
47    pub insert_indices: Vec<usize>,
48    pub delete_indices: Vec<usize>,
49    pub payload: Vec<String>,
50    pub start_line: usize,
51    pub end_line: usize,
52    pub op_index: usize,
53}
54
55/// Detect a replacement group starting at `start` in the edit list.
56pub fn find_replacement_group(edits: &[LineEdit], start: usize) -> Option<ReplacementGroup> {
57    let LineEdit::Insert {
58        anchor,
59        place: InsertPlace::Before,
60        mode: InsertMode::Replacement,
61        op_index,
62        ..
63    } = edits.get(start)?
64    else {
65        return None;
66    };
67    let anchor_line = *anchor;
68    let op_index = *op_index;
69    let mut insert_indices = Vec::new();
70    let mut payload = Vec::new();
71    let mut i = start;
72    while i < edits.len() {
73        match &edits[i] {
74            LineEdit::Insert {
75                anchor,
76                place: InsertPlace::Before,
77                text,
78                mode: InsertMode::Replacement,
79                op_index: edit_op,
80            } if *anchor == anchor_line && *edit_op == op_index => {
81                insert_indices.push(i);
82                payload.push(text.clone());
83                i += 1;
84            }
85            _ => break,
86        }
87    }
88    let mut delete_indices = Vec::new();
89    let mut expected = anchor_line;
90    while i < edits.len() {
91        match &edits[i] {
92            LineEdit::Delete {
93                line,
94                op_index: edit_op,
95            } if *line == expected && *edit_op == op_index => {
96                delete_indices.push(i);
97                expected += 1;
98                i += 1;
99            }
100            _ => break,
101        }
102    }
103    if delete_indices.is_empty() {
104        return None;
105    }
106    Some(ReplacementGroup {
107        insert_indices,
108        delete_indices,
109        payload,
110        start_line: anchor_line,
111        end_line: expected - 1,
112        op_index,
113    })
114}
115
116/// Coalesce adjacent or overlapping replacement groups that share the same
117/// source operation into one contiguous replacement. This is the
118/// replacement-coalescing repair layer: agents sometimes emit several
119/// single-line PUTs that together replace a contiguous span.
120pub fn coalesce_replacement_edits(edits: &[LineEdit]) -> Vec<LineEdit> {
121    if edits.is_empty() {
122        return Vec::new();
123    }
124
125    // Group by op_index while preserving first-seen order.
126    let mut op_order: Vec<usize> = Vec::new();
127    let mut by_op: std::collections::BTreeMap<usize, Vec<LineEdit>> =
128        std::collections::BTreeMap::new();
129    for edit in edits {
130        let op = match edit {
131            LineEdit::Insert { op_index, .. } | LineEdit::Delete { op_index, .. } => *op_index,
132        };
133        if !by_op.contains_key(&op) {
134            op_order.push(op);
135        }
136        by_op.entry(op).or_default().push(edit.clone());
137    }
138
139    let mut out = Vec::with_capacity(edits.len());
140    for op in op_order {
141        let group = by_op.remove(&op).unwrap_or_default();
142        out.extend(coalesce_one_op(group));
143    }
144    out
145}
146
147fn coalesce_one_op(edits: Vec<LineEdit>) -> Vec<LineEdit> {
148    let mut deletes: Vec<usize> = edits
149        .iter()
150        .filter_map(|edit| match edit {
151            LineEdit::Delete { line, .. } => Some(*line),
152            _ => None,
153        })
154        .collect();
155    let replacements: Vec<String> = edits
156        .iter()
157        .filter_map(|edit| match edit {
158            LineEdit::Insert {
159                text,
160                mode: InsertMode::Replacement,
161                ..
162            } => Some(text.clone()),
163            _ => None,
164        })
165        .collect();
166    let plain: Vec<LineEdit> = edits
167        .iter()
168        .filter(|edit| {
169            !matches!(
170                edit,
171                LineEdit::Delete { .. }
172                    | LineEdit::Insert {
173                        mode: InsertMode::Replacement,
174                        ..
175                    }
176            )
177        })
178        .cloned()
179        .collect();
180
181    if deletes.is_empty() || replacements.is_empty() {
182        return edits;
183    }
184
185    deletes.sort_unstable();
186    deletes.dedup();
187    // Only coalesce when deletes form one contiguous span.
188    let contiguous = deletes
189        .windows(2)
190        .all(|pair| pair[1] == pair[0].saturating_add(1));
191    if !contiguous {
192        return edits;
193    }
194    let start = deletes[0];
195    let end = *deletes.last().unwrap();
196    let op_index = match edits.first() {
197        Some(LineEdit::Insert { op_index, .. } | LineEdit::Delete { op_index, .. }) => *op_index,
198        None => 0,
199    };
200
201    let mut coalesced = plain;
202    for text in replacements {
203        coalesced.push(LineEdit::Insert {
204            anchor: start,
205            place: InsertPlace::Before,
206            text,
207            mode: InsertMode::Replacement,
208            op_index,
209        });
210    }
211    for line in start..=end {
212        coalesced.push(LineEdit::Delete { line, op_index });
213    }
214    coalesced
215}
216
217/// Splice edits into content lines. Coordinates are pre-request (baseline).
218pub fn materialize_edits(original_lines: &[String], edits: &[LineEdit]) -> Vec<String> {
219    let mut file_lines = original_lines.to_vec();
220    let mut bof: Vec<String> = Vec::new();
221    let mut eof: Vec<String> = Vec::new();
222
223    // Bucket anchor-targeted edits by line.
224    let mut by_line: std::collections::BTreeMap<usize, Vec<&LineEdit>> =
225        std::collections::BTreeMap::new();
226    for edit in edits {
227        match edit {
228            LineEdit::Insert {
229                place: InsertPlace::Bof,
230                text,
231                ..
232            } => bof.push(text.clone()),
233            LineEdit::Insert {
234                place: InsertPlace::Eof,
235                text,
236                ..
237            } => eof.push(text.clone()),
238            LineEdit::Insert { anchor, .. } => {
239                by_line.entry(*anchor).or_default().push(edit);
240            }
241            LineEdit::Delete { line, .. } => {
242                by_line.entry(*line).or_default().push(edit);
243            }
244        }
245    }
246
247    // Apply bottom-up so earlier indices stay valid.
248    let lines: Vec<usize> = by_line.keys().copied().collect();
249    for line in lines.into_iter().rev() {
250        let Some(bucket) = by_line.get(&line) else {
251            continue;
252        };
253        let idx = line.saturating_sub(1);
254        if idx > file_lines.len() {
255            continue;
256        }
257        let current = file_lines.get(idx).cloned().unwrap_or_default();
258        let mut before = Vec::new();
259        let mut after = Vec::new();
260        let mut replacement = Vec::new();
261        let mut delete_line = false;
262        for edit in bucket {
263            match edit {
264                LineEdit::Insert {
265                    place: InsertPlace::Before,
266                    text,
267                    mode: InsertMode::Replacement,
268                    ..
269                } => replacement.push(text.clone()),
270                LineEdit::Insert {
271                    place: InsertPlace::Before,
272                    text,
273                    ..
274                } => before.push(text.clone()),
275                LineEdit::Insert {
276                    place: InsertPlace::After,
277                    text,
278                    ..
279                } => after.push(text.clone()),
280                LineEdit::Delete { .. } => delete_line = true,
281                LineEdit::Insert {
282                    place: InsertPlace::Bof | InsertPlace::Eof,
283                    ..
284                } => {}
285            }
286        }
287        if before.is_empty() && replacement.is_empty() && after.is_empty() && !delete_line {
288            continue;
289        }
290        let spliced = if delete_line {
291            let mut rows = before;
292            rows.extend(replacement);
293            rows.extend(after);
294            rows
295        } else {
296            let mut rows = before;
297            rows.extend(replacement);
298            if idx < file_lines.len() {
299                rows.push(current);
300            }
301            rows.extend(after);
302            rows
303        };
304        if idx < file_lines.len() {
305            file_lines.splice(idx..=idx, spliced);
306        } else {
307            file_lines.extend(spliced);
308        }
309    }
310
311    if !bof.is_empty() {
312        let mut rows = bof;
313        rows.append(&mut file_lines);
314        file_lines = rows;
315    }
316    file_lines.extend(eof);
317    file_lines
318}
319
320/// Rebuild file bytes from logical lines using the baseline terminator policy.
321pub fn join_lines(
322    lines: &[String],
323    default_terminator: TerminatorKind,
324    trailing_terminator: bool,
325) -> Vec<u8> {
326    if lines.is_empty() {
327        return Vec::new();
328    }
329    let mut out = Vec::new();
330    for (index, line) in lines.iter().enumerate() {
331        out.extend_from_slice(line.as_bytes());
332        let is_last = index + 1 == lines.len();
333        let term = if is_last && !trailing_terminator {
334            TerminatorKind::None
335        } else if default_terminator == TerminatorKind::None {
336            TerminatorKind::Lf
337        } else {
338            default_terminator
339        };
340        match term {
341            TerminatorKind::Lf => out.push(b'\n'),
342            TerminatorKind::CrLf => out.extend_from_slice(b"\r\n"),
343            TerminatorKind::None => {}
344        }
345    }
346    out
347}
348
349/// Infer terminator policy from baseline raw records.
350pub fn terminator_policy(
351    records: &std::collections::BTreeMap<usize, crate::hashline::scan::RawLineRecord>,
352) -> (TerminatorKind, bool) {
353    let mut lf = 0usize;
354    let mut crlf = 0usize;
355    let mut trailing = false;
356    let last = records.keys().next_back().copied();
357    for (&line, record) in records {
358        match record.terminator {
359            TerminatorKind::Lf => lf += 1,
360            TerminatorKind::CrLf => crlf += 1,
361            TerminatorKind::None => {}
362        }
363        if Some(line) == last {
364            trailing = record.terminator != TerminatorKind::None;
365        }
366    }
367    let default = if crlf > lf {
368        TerminatorKind::CrLf
369    } else if lf > 0 || crlf > 0 {
370        TerminatorKind::Lf
371    } else {
372        TerminatorKind::Lf
373    };
374    (default, trailing || records.is_empty())
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn materialize_replaces_a_span() {
383        let original = vec!["a".into(), "b".into(), "c".into()];
384        let edits = vec![
385            LineEdit::Insert {
386                anchor: 2,
387                place: InsertPlace::Before,
388                text: "B".into(),
389                mode: InsertMode::Replacement,
390                op_index: 0,
391            },
392            LineEdit::Delete {
393                line: 2,
394                op_index: 0,
395            },
396        ];
397        assert_eq!(
398            materialize_edits(&original, &edits),
399            vec!["a".to_string(), "B".into(), "c".into()]
400        );
401    }
402
403    #[test]
404    fn coalesce_merges_contiguous_single_line_replacements() {
405        let edits = vec![
406            LineEdit::Insert {
407                anchor: 1,
408                place: InsertPlace::Before,
409                text: "A".into(),
410                mode: InsertMode::Replacement,
411                op_index: 0,
412            },
413            LineEdit::Delete {
414                line: 1,
415                op_index: 0,
416            },
417            LineEdit::Insert {
418                anchor: 2,
419                place: InsertPlace::Before,
420                text: "B".into(),
421                mode: InsertMode::Replacement,
422                op_index: 0,
423            },
424            LineEdit::Delete {
425                line: 2,
426                op_index: 0,
427            },
428            LineEdit::Insert {
429                anchor: 3,
430                place: InsertPlace::Before,
431                text: "C".into(),
432                mode: InsertMode::Replacement,
433                op_index: 0,
434            },
435            LineEdit::Delete {
436                line: 3,
437                op_index: 0,
438            },
439        ];
440        let coalesced = coalesce_replacement_edits(&edits);
441        let group = find_replacement_group(&coalesced, 0).expect("one group");
442        assert_eq!(group.start_line, 1);
443        assert_eq!(group.end_line, 3);
444        assert_eq!(group.payload, vec!["A", "B", "C"]);
445    }
446}