Skip to main content

weavatrix_edit/application/
prepared.rs

1use crate::error::{EditError, ErrorCode};
2use crate::model::Provenance;
3
4use core::ops::Range;
5
6use super::{
7    PreparedEdit, ProvenanceSet,
8    ranges::{prepared_output_size, sort_prepared, verify_ranges},
9    stream::EditChunks,
10    writer::{WriteSummary, write_prepared},
11};
12
13/// Successful all-or-nothing in-memory application.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct AppliedText {
16    pub text: String,
17    pub edits_applied: usize,
18    pub bytes_before: usize,
19    pub bytes_after: usize,
20}
21
22/// Bias used when an original offset sits on an edit boundary.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum OffsetBias {
25    Left,
26    Right,
27}
28
29/// One normalized edit with exact source and resulting byte ranges.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct PreparedChange<'change> {
32    pub source_range: Range<usize>,
33    pub output_range: Range<usize>,
34    pub before: &'change str,
35    pub after: &'change str,
36    pub input_order: usize,
37    provenance: &'change ProvenanceSet,
38}
39
40impl PreparedChange<'_> {
41    /// Primary provenance retained from the first equivalent prepared edit.
42    #[must_use]
43    pub fn provenance(&self) -> &Provenance {
44        &self.provenance.primary
45    }
46
47    /// Every distinct provenance retained across equivalent unioned edits.
48    pub fn provenances(&self) -> impl Iterator<Item = &Provenance> {
49        core::iter::once(&self.provenance.primary).chain(self.provenance.additional.iter())
50    }
51
52    #[must_use]
53    pub fn provenance_count(&self) -> usize {
54        1 + self.provenance.additional.len()
55    }
56}
57
58/// Allocation-free iterator over normalized prepared changes.
59#[derive(Clone, Debug)]
60pub struct PreparedChanges<'change> {
61    source: &'change str,
62    edits: core::slice::Iter<'change, PreparedEdit>,
63    source_cursor: usize,
64    output_cursor: usize,
65}
66
67impl<'change> Iterator for PreparedChanges<'change> {
68    type Item = PreparedChange<'change>;
69
70    fn next(&mut self) -> Option<Self::Item> {
71        let edit = self.edits.next()?;
72        let unchanged = edit.start - self.source_cursor;
73        let output_start = self.output_cursor + unchanged;
74        let output_end = output_start + edit.after.len();
75        self.source_cursor = self.source_cursor.max(edit.end);
76        self.output_cursor = output_end;
77        Some(PreparedChange {
78            source_range: edit.start..edit.end,
79            output_range: output_start..output_end,
80            before: &self.source[edit.start..edit.end],
81            after: &edit.after,
82            input_order: edit.order,
83            provenance: &edit.provenance,
84        })
85    }
86
87    fn size_hint(&self) -> (usize, Option<usize>) {
88        self.edits.size_hint()
89    }
90}
91
92impl ExactSizeIterator for PreparedChanges<'_> {}
93impl core::iter::FusedIterator for PreparedChanges<'_> {}
94
95/// Exact aggregate sizes for a prepared change set.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub struct ChangeSummary {
98    pub edits: usize,
99    pub bytes_before: usize,
100    pub bytes_after: usize,
101    pub removed_bytes: usize,
102    pub inserted_bytes: usize,
103}
104
105/// Validated, sorted byte edits bound to one immutable source revision.
106#[derive(Clone, Debug)]
107pub struct PreparedEdits<'source> {
108    source: &'source str,
109    edits: Vec<PreparedEdit>,
110    output_size: usize,
111    max_edits: usize,
112    max_output_size: usize,
113}
114
115impl<'source> PreparedEdits<'source> {
116    pub(super) fn from_validated_parts(
117        source: &'source str,
118        edits: Vec<PreparedEdit>,
119        output_size: usize,
120        max_edits: usize,
121        max_output_size: usize,
122    ) -> Self {
123        Self {
124            source,
125            edits,
126            output_size,
127            max_edits,
128            max_output_size,
129        }
130    }
131
132    /// Applies the already-prepared edits with one output allocation.
133    #[must_use]
134    pub fn apply(&self) -> AppliedText {
135        let mut text = String::with_capacity(self.output_size);
136        let mut cursor = 0_usize;
137        for edit in &self.edits {
138            if edit.start > cursor {
139                text.push_str(&self.source[cursor..edit.start]);
140                cursor = edit.start;
141            }
142            text.push_str(&edit.after);
143            if edit.end > edit.start {
144                cursor = edit.end;
145            }
146        }
147        text.push_str(&self.source[cursor..]);
148        AppliedText {
149            bytes_before: self.source.len(),
150            bytes_after: text.len(),
151            edits_applied: self.edits.len(),
152            text,
153        }
154    }
155
156    /// Iterates over the validated output without allocating a final [`String`].
157    ///
158    /// This is the sink-independent streaming surface. Callers can forward the
159    /// borrowed chunks to synchronous or asynchronous writers without adding an
160    /// async runtime dependency to this crate.
161    #[must_use]
162    pub fn chunks(&self) -> EditChunks<'_> {
163        EditChunks::new(self.source, &self.edits)
164    }
165
166    /// Iterates exact normalized changes without constructing output or a diff.
167    ///
168    /// Source and output ranges are UTF-8 byte ranges. Multiple inserts at one
169    /// source offset retain deterministic input order and receive consecutive
170    /// output ranges. Identical replacements merged by [`Self::union`] retain
171    /// every distinct provenance label.
172    #[must_use]
173    pub fn changes(&self) -> PreparedChanges<'_> {
174        PreparedChanges {
175            source: self.source,
176            edits: self.edits.iter(),
177            source_cursor: 0,
178            output_cursor: 0,
179        }
180    }
181
182    /// Returns exact edit and byte totals without applying or allocating output.
183    #[must_use]
184    pub fn change_summary(&self) -> ChangeSummary {
185        let removed_bytes = self.edits.iter().map(|edit| edit.end - edit.start).sum();
186        let inserted_bytes = self.edits.iter().map(|edit| edit.after.len()).sum();
187        ChangeSummary {
188            edits: self.edits.len(),
189            bytes_before: self.source.len(),
190            bytes_after: self.output_size,
191            removed_bytes,
192            inserted_bytes,
193        }
194    }
195
196    /// Writes the already-validated result without allocating an output [`String`].
197    ///
198    /// Edit validation is atomic: construction of this value completed before the
199    /// first write. An I/O failure can still leave a non-transactional sink with a
200    /// prefix of the result, so callers requiring sink atomicity should write to a
201    /// temporary file and rename it after success. This method does not call
202    /// [`std::io::Write::flush`] or request durable storage synchronization.
203    pub fn write_to<W: std::io::Write + ?Sized>(
204        &self,
205        writer: &mut W,
206    ) -> std::io::Result<WriteSummary> {
207        write_prepared(
208            writer,
209            self.chunks(),
210            self.source.len(),
211            self.edits.len(),
212            self.output_size,
213        )
214    }
215
216    /// Applies edits and accepts only output approved by `validator`.
217    pub fn apply_with_validator(
218        &self,
219        validator: impl FnOnce(&str) -> bool,
220    ) -> Result<AppliedText, EditError> {
221        let applied = self.apply();
222        if validator(&applied.text) {
223            Ok(applied)
224        } else {
225            Err(EditError::new(
226                ErrorCode::ValidationRejected,
227                "the output validator rejected the complete edit result",
228            ))
229        }
230    }
231
232    /// Merges another prepared set over identical source text.
233    ///
234    /// Inserts from `self` precede inserts from `other` at the same offset.
235    pub fn union(mut self, mut other: Self) -> Result<Self, EditError> {
236        if self.source != other.source {
237            return Err(EditError::new(
238                ErrorCode::InvalidEdit,
239                "prepared edits can only be merged over identical source text",
240            ));
241        }
242        self.max_edits = self.max_edits.min(other.max_edits);
243        self.max_output_size = self.max_output_size.min(other.max_output_size);
244        let order_base = self
245            .edits
246            .iter()
247            .map(|edit| edit.order)
248            .max()
249            .map_or(Ok(0), |order| {
250                order.checked_add(1).ok_or_else(|| {
251                    EditError::new(ErrorCode::PlanTooLarge, "merged edit order overflow")
252                })
253            })?;
254        for edit in &mut other.edits {
255            edit.order = order_base.checked_add(edit.order).ok_or_else(|| {
256                EditError::new(ErrorCode::PlanTooLarge, "merged edit order overflow")
257            })?;
258        }
259        self.edits.extend(other.edits);
260        sort_prepared(&mut self.edits);
261        self.edits.dedup_by(|right, left| {
262            let identical = left.start != left.end
263                && left.start == right.start
264                && left.end == right.end
265                && left.after == right.after;
266            if identical {
267                let placeholder = ProvenanceSet::new(right.provenance.primary.clone());
268                let other = core::mem::replace(&mut right.provenance, placeholder);
269                left.provenance.extend(other);
270            }
271            identical
272        });
273        if self.edits.len() > self.max_edits {
274            return Err(EditError::new(
275                ErrorCode::PlanTooLarge,
276                "merged edit count exceeds the application limit",
277            ));
278        }
279        verify_ranges(
280            self.edits
281                .iter()
282                .map(|edit| (edit.start, edit.end, edit.order)),
283        )?;
284        self.output_size =
285            prepared_output_size(self.source.len(), &self.edits, self.max_output_size)?;
286        Ok(self)
287    }
288
289    /// Returns whether an offset lies strictly inside replaced/deleted source.
290    #[must_use]
291    pub fn invalidates_offset(&self, offset: usize) -> bool {
292        self.edits
293            .iter()
294            .any(|edit| edit.start < offset && offset < edit.end)
295    }
296
297    /// Maps an original UTF-8 byte boundary into the resulting text.
298    #[must_use]
299    pub fn map_offset_forward(&self, offset: usize, bias: OffsetBias) -> Option<usize> {
300        if offset > self.source.len() || !self.source.is_char_boundary(offset) {
301            return None;
302        }
303        let mut delta = 0_i128;
304        for edit in &self.edits {
305            if offset < edit.start {
306                break;
307            }
308            let mapped_start = shifted(edit.start, delta)?;
309            if edit.start < offset && offset < edit.end {
310                return None;
311            }
312            if edit.start == edit.end && offset == edit.start {
313                if bias == OffsetBias::Left {
314                    return Some(mapped_start);
315                }
316                delta += i128::try_from(edit.after.len()).ok()?;
317                continue;
318            }
319            if offset == edit.start && edit.end > edit.start {
320                return Some(match bias {
321                    OffsetBias::Left => mapped_start,
322                    OffsetBias::Right => mapped_start.checked_add(edit.after.len())?,
323                });
324            }
325            if offset >= edit.end {
326                delta += i128::try_from(edit.after.len()).ok()?
327                    - i128::try_from(edit.end - edit.start).ok()?;
328            }
329        }
330        shifted(offset, delta)
331    }
332
333    #[must_use]
334    pub fn len(&self) -> usize {
335        self.edits.len()
336    }
337
338    /// Returns the immutable source size used to validate this plan.
339    #[must_use]
340    pub fn bytes_before(&self) -> usize {
341        self.source.len()
342    }
343
344    /// Returns the exact output size computed before application or streaming.
345    #[must_use]
346    pub fn bytes_after(&self) -> usize {
347        self.output_size
348    }
349
350    #[must_use]
351    pub fn is_empty(&self) -> bool {
352        self.edits.is_empty()
353    }
354}
355
356fn shifted(offset: usize, delta: i128) -> Option<usize> {
357    usize::try_from(i128::try_from(offset).ok()?.checked_add(delta)?).ok()
358}