Skip to main content

weavatrix_edit/application/
prepared.rs

1use crate::error::{EditError, ErrorCode};
2use crate::model::Provenance;
3
4use core::ops::Range;
5use std::sync::OnceLock;
6
7use super::{
8    InsertRun, PreparedEdit, ProvenanceSet,
9    ranges::{prepared_output_size, sort_prepared, verify_ranges},
10    stream::EditChunks,
11    writer::{WriteSummary, write_prepared},
12};
13
14const MAX_COALESCED_INSERT_BYTES: usize = 64 * 1024;
15
16/// Successful all-or-nothing in-memory application.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct AppliedText {
19    pub text: String,
20    pub edits_applied: usize,
21    pub bytes_before: usize,
22    pub bytes_after: usize,
23}
24
25/// Aggregate result of applying a prepared plan into caller-owned storage.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct ApplySummary {
28    pub edits_applied: usize,
29    pub bytes_before: usize,
30    pub bytes_after: usize,
31}
32
33/// Bias used when an original offset sits on an edit boundary.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum OffsetBias {
36    Left,
37    Right,
38}
39
40/// One normalized edit with exact source and resulting byte ranges.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct PreparedChange<'change> {
43    pub source_range: Range<usize>,
44    pub output_range: Range<usize>,
45    pub before: &'change str,
46    pub after: &'change str,
47    pub input_order: usize,
48    provenance: &'change ProvenanceSet,
49}
50
51impl PreparedChange<'_> {
52    /// Primary provenance retained from the first equivalent prepared edit.
53    #[must_use]
54    pub fn provenance(&self) -> &Provenance {
55        &self.provenance.primary
56    }
57
58    /// Every distinct provenance retained across equivalent unioned edits.
59    pub fn provenances(&self) -> impl Iterator<Item = &Provenance> {
60        core::iter::once(&self.provenance.primary).chain(self.provenance.additional().iter())
61    }
62
63    #[must_use]
64    pub fn provenance_count(&self) -> usize {
65        1 + self.provenance.additional().len()
66    }
67}
68
69/// Allocation-free iterator over normalized prepared changes.
70#[derive(Clone, Debug)]
71pub struct PreparedChanges<'change> {
72    source: &'change str,
73    edits: core::slice::Iter<'change, PreparedEdit>,
74    source_cursor: usize,
75    output_cursor: usize,
76}
77
78impl<'change> Iterator for PreparedChanges<'change> {
79    type Item = PreparedChange<'change>;
80
81    fn next(&mut self) -> Option<Self::Item> {
82        let edit = self.edits.next()?;
83        let unchanged = edit.start - self.source_cursor;
84        let output_start = self.output_cursor + unchanged;
85        let output_end = output_start + edit.after.len();
86        self.source_cursor = self.source_cursor.max(edit.end);
87        self.output_cursor = output_end;
88        Some(PreparedChange {
89            source_range: edit.start..edit.end,
90            output_range: output_start..output_end,
91            before: &self.source[edit.start..edit.end],
92            after: &edit.after,
93            input_order: edit.order,
94            provenance: &edit.provenance,
95        })
96    }
97
98    fn size_hint(&self) -> (usize, Option<usize>) {
99        self.edits.size_hint()
100    }
101}
102
103impl ExactSizeIterator for PreparedChanges<'_> {}
104impl core::iter::FusedIterator for PreparedChanges<'_> {}
105
106/// Exact aggregate sizes for a prepared change set.
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub struct ChangeSummary {
109    pub edits: usize,
110    pub bytes_before: usize,
111    pub bytes_after: usize,
112    pub removed_bytes: usize,
113    pub inserted_bytes: usize,
114}
115
116/// Validated, sorted byte edits bound to one immutable source revision.
117///
118/// Application metadata may retain at most 64 KiB of coalesced insertion text
119/// to accelerate repeated same-offset runs while preserving every logical
120/// edit. The optional complete output cache is initialized only by
121/// [`Self::rendered_text`] and can be released with
122/// [`Self::clear_rendered_text`].
123#[derive(Debug)]
124pub struct PreparedEdits<'source> {
125    source: &'source str,
126    edits: Vec<PreparedEdit>,
127    output_size: usize,
128    max_edits: usize,
129    max_output_size: usize,
130    same_size: bool,
131    insert_runs: Vec<InsertRun>,
132    rendered: OnceLock<String>,
133}
134
135impl Clone for PreparedEdits<'_> {
136    fn clone(&self) -> Self {
137        Self {
138            source: self.source,
139            edits: self.edits.clone(),
140            output_size: self.output_size,
141            max_edits: self.max_edits,
142            max_output_size: self.max_output_size,
143            same_size: self.same_size,
144            insert_runs: self.insert_runs.clone(),
145            // Materialization is a replay optimization, not part of the edit
146            // plan. Avoid unexpectedly cloning an output-sized cache.
147            rendered: OnceLock::new(),
148        }
149    }
150}
151
152impl<'source> PreparedEdits<'source> {
153    pub(super) fn from_validated_parts(
154        source: &'source str,
155        edits: Vec<PreparedEdit>,
156        output_size: usize,
157        max_edits: usize,
158        max_output_size: usize,
159    ) -> Self {
160        let same_size = edits
161            .iter()
162            .all(|edit| edit.end - edit.start == edit.after.len());
163        let insert_runs = coalesce_insert_runs(&edits);
164        Self {
165            source,
166            edits,
167            output_size,
168            max_edits,
169            max_output_size,
170            same_size,
171            insert_runs,
172            rendered: OnceLock::new(),
173        }
174    }
175
176    /// Applies the already-prepared edits with one output allocation.
177    ///
178    /// This does not retain an output-sized cache unless the caller explicitly
179    /// initialized one through [`Self::rendered_text`].
180    #[must_use]
181    #[inline]
182    pub fn apply(&self) -> AppliedText {
183        let text = self.rendered.get().map_or_else(
184            || {
185                let mut output = String::with_capacity(self.output_size);
186                self.fill_output(&mut output);
187                output
188            },
189            Clone::clone,
190        );
191        AppliedText {
192            bytes_before: self.source.len(),
193            bytes_after: text.len(),
194            edits_applied: self.edits.len(),
195            text,
196        }
197    }
198
199    /// Applies into caller-owned storage, retaining its allocation for replay.
200    ///
201    /// The output is cleared only after this plan has already completed all
202    /// exact-before, Unicode-boundary, overlap, and hard-limit validation.
203    /// Reusing the same `String` therefore removes allocator traffic without
204    /// weakening the all-or-nothing admission contract. When the caller has
205    /// explicitly initialized [`Self::rendered_text`], replay becomes one
206    /// contiguous copy; otherwise this walks the normalized edit list without
207    /// retaining an output-sized cache.
208    #[inline]
209    pub fn apply_into(&self, output: &mut String) -> ApplySummary {
210        if let Some(rendered) = self.rendered.get() {
211            output.clone_from(rendered);
212        } else {
213            output.clear();
214            if output.capacity() < self.output_size {
215                output.reserve(self.output_size);
216            }
217            self.fill_output(output);
218        }
219        ApplySummary {
220            edits_applied: self.edits.len(),
221            bytes_before: self.source.len(),
222            bytes_after: output.len(),
223        }
224    }
225
226    /// Applies into a caller-owned byte buffer, retaining its allocation.
227    ///
228    /// The bytes are guaranteed to be valid UTF-8 because the source and every
229    /// replacement are validated Rust strings. This avoids a temporary
230    /// `String` when the next stage is a file, socket, hash, or byte pipeline.
231    /// When the caller has explicitly initialized [`Self::rendered_text`],
232    /// replay becomes one contiguous copy; otherwise this walks the normalized
233    /// edit list without retaining an output-sized cache.
234    #[inline]
235    pub fn apply_into_bytes(&self, output: &mut Vec<u8>) -> ApplySummary {
236        output.clear();
237        if output.capacity() < self.output_size {
238            output.reserve(self.output_size);
239        }
240        if let Some(rendered) = self.rendered.get() {
241            output.extend_from_slice(rendered.as_bytes());
242        } else if self.same_size {
243            output.extend_from_slice(self.source.as_bytes());
244            for edit in &self.edits {
245                output[edit.start..edit.end].copy_from_slice(edit.after.as_bytes());
246            }
247        } else {
248            self.fill_output_bytes(output);
249        }
250        ApplySummary {
251            edits_applied: self.edits.len(),
252            bytes_before: self.source.len(),
253            bytes_after: output.len(),
254        }
255    }
256
257    #[inline]
258    fn fill_output(&self, output: &mut String) {
259        if self.same_size {
260            output.push_str(self.source);
261            for edit in &self.edits {
262                output.replace_range(edit.start..edit.end, &edit.after);
263            }
264            return;
265        }
266        if !self.insert_runs.is_empty() {
267            self.fill_output_with_insert_runs(output);
268            return;
269        }
270        let mut cursor = 0_usize;
271        for edit in &self.edits {
272            if edit.start > cursor {
273                output.push_str(&self.source[cursor..edit.start]);
274                cursor = edit.start;
275            }
276            output.push_str(&edit.after);
277            if edit.end > edit.start {
278                cursor = edit.end;
279            }
280        }
281        output.push_str(&self.source[cursor..]);
282    }
283
284    fn fill_output_bytes(&self, output: &mut Vec<u8>) {
285        if !self.insert_runs.is_empty() {
286            self.fill_output_bytes_with_insert_runs(output);
287            return;
288        }
289        let mut cursor = 0_usize;
290        let source = self.source.as_bytes();
291        for edit in &self.edits {
292            if edit.start > cursor {
293                output.extend_from_slice(&source[cursor..edit.start]);
294                cursor = edit.start;
295            }
296            output.extend_from_slice(edit.after.as_bytes());
297            if edit.end > edit.start {
298                cursor = edit.end;
299            }
300        }
301        output.extend_from_slice(&source[cursor..]);
302    }
303
304    fn fill_output_with_insert_runs(&self, output: &mut String) {
305        let mut cursor = 0_usize;
306        self.for_each_execution_chunk(|start, end, after| {
307            if start > cursor {
308                output.push_str(&self.source[cursor..start]);
309                cursor = start;
310            }
311            output.push_str(after);
312            if end > start {
313                cursor = end;
314            }
315        });
316        output.push_str(&self.source[cursor..]);
317    }
318
319    fn fill_output_bytes_with_insert_runs(&self, output: &mut Vec<u8>) {
320        let mut cursor = 0_usize;
321        let source = self.source.as_bytes();
322        self.for_each_execution_chunk(|start, end, after| {
323            if start > cursor {
324                output.extend_from_slice(&source[cursor..start]);
325                cursor = start;
326            }
327            output.extend_from_slice(after.as_bytes());
328            if end > start {
329                cursor = end;
330            }
331        });
332        output.extend_from_slice(&source[cursor..]);
333    }
334
335    fn for_each_execution_chunk(&self, mut visit: impl FnMut(usize, usize, &str)) {
336        let mut edit_index = 0_usize;
337        let mut run_index = 0_usize;
338        while edit_index < self.edits.len() {
339            if let Some(run) = self.insert_runs.get(run_index)
340                && run.first_edit == edit_index
341            {
342                visit(run.start, run.start, &run.after);
343                edit_index = run.past_last_edit;
344                run_index += 1;
345            } else {
346                let edit = &self.edits[edit_index];
347                visit(edit.start, edit.end, &edit.after);
348                edit_index += 1;
349            }
350        }
351    }
352
353    #[inline]
354    fn rendered_output(&self) -> &String {
355        self.rendered.get_or_init(|| {
356            let mut output = String::with_capacity(self.output_size);
357            self.fill_output(&mut output);
358            output
359        })
360    }
361
362    /// Returns a lazily materialized, cached view of the complete output.
363    ///
364    /// This is the zero-copy replay surface for consumers that can borrow the
365    /// result. The first call allocates and renders the output; later calls are
366    /// constant-time. Streaming through [`Self::chunks`] or [`Self::write_to`]
367    /// does not initialize this cache.
368    #[must_use]
369    #[inline]
370    pub fn rendered_text(&self) -> &str {
371        self.rendered_output()
372    }
373
374    /// Returns whether [`Self::rendered_text`] currently retains an
375    /// output-sized materialization.
376    #[must_use]
377    pub fn has_rendered_text(&self) -> bool {
378        self.rendered.get().is_some()
379    }
380
381    /// Releases the optional output-sized materialization.
382    ///
383    /// The normalized edit plan remains valid and later uncached applications
384    /// still use the same atomic admission result.
385    pub fn clear_rendered_text(&mut self) {
386        drop(self.rendered.take());
387    }
388
389    /// Iterates over the validated output without allocating a final [`String`].
390    ///
391    /// This is the sink-independent streaming surface. Callers can forward the
392    /// borrowed chunks to synchronous or asynchronous writers without adding an
393    /// async runtime dependency to this crate.
394    #[must_use]
395    pub fn chunks(&self) -> EditChunks<'_> {
396        EditChunks::new(self.source, &self.edits)
397    }
398
399    /// Iterates exact normalized changes without constructing output or a diff.
400    ///
401    /// Source and output ranges are UTF-8 byte ranges. Multiple inserts at one
402    /// source offset retain deterministic input order and receive consecutive
403    /// output ranges. Identical replacements merged by [`Self::union`] retain
404    /// every distinct provenance label.
405    #[must_use]
406    pub fn changes(&self) -> PreparedChanges<'_> {
407        PreparedChanges {
408            source: self.source,
409            edits: self.edits.iter(),
410            source_cursor: 0,
411            output_cursor: 0,
412        }
413    }
414
415    /// Returns exact edit and byte totals without applying or allocating output.
416    #[must_use]
417    pub fn change_summary(&self) -> ChangeSummary {
418        let removed_bytes = self.edits.iter().map(|edit| edit.end - edit.start).sum();
419        let inserted_bytes = self.edits.iter().map(|edit| edit.after.len()).sum();
420        ChangeSummary {
421            edits: self.edits.len(),
422            bytes_before: self.source.len(),
423            bytes_after: self.output_size,
424            removed_bytes,
425            inserted_bytes,
426        }
427    }
428
429    /// Writes the already-validated result without allocating an output [`String`].
430    ///
431    /// Edit validation is atomic: construction of this value completed before the
432    /// first write. An I/O failure can still leave a non-transactional sink with a
433    /// prefix of the result, so callers requiring sink atomicity should write to a
434    /// temporary file and rename it after success. This method does not call
435    /// [`std::io::Write::flush`] or request durable storage synchronization.
436    pub fn write_to<W: std::io::Write + ?Sized>(
437        &self,
438        writer: &mut W,
439    ) -> std::io::Result<WriteSummary> {
440        write_prepared(
441            writer,
442            self.chunks(),
443            self.source.len(),
444            self.edits.len(),
445            self.output_size,
446        )
447    }
448
449    /// Applies edits and accepts only output approved by `validator`.
450    pub fn apply_with_validator(
451        &self,
452        validator: impl FnOnce(&str) -> bool,
453    ) -> Result<AppliedText, EditError> {
454        let applied = self.apply();
455        if validator(&applied.text) {
456            Ok(applied)
457        } else {
458            Err(EditError::new(
459                ErrorCode::ValidationRejected,
460                "the output validator rejected the complete edit result",
461            ))
462        }
463    }
464
465    /// Merges another prepared set over identical source text.
466    ///
467    /// Inserts from `self` precede inserts from `other` at the same offset.
468    pub fn union(mut self, mut other: Self) -> Result<Self, EditError> {
469        if self.source != other.source {
470            return Err(EditError::new(
471                ErrorCode::InvalidEdit,
472                "prepared edits can only be merged over identical source text",
473            ));
474        }
475        self.max_edits = self.max_edits.min(other.max_edits);
476        self.max_output_size = self.max_output_size.min(other.max_output_size);
477        let order_base = self
478            .edits
479            .iter()
480            .map(|edit| edit.order)
481            .max()
482            .map_or(Ok(0), |order| {
483                order.checked_add(1).ok_or_else(|| {
484                    EditError::new(ErrorCode::PlanTooLarge, "merged edit order overflow")
485                })
486            })?;
487        for edit in &mut other.edits {
488            edit.order = order_base.checked_add(edit.order).ok_or_else(|| {
489                EditError::new(ErrorCode::PlanTooLarge, "merged edit order overflow")
490            })?;
491        }
492        self.edits.extend(other.edits);
493        sort_prepared(&mut self.edits);
494        self.edits.dedup_by(|right, left| {
495            let identical = left.start != left.end
496                && left.start == right.start
497                && left.end == right.end
498                && left.after == right.after;
499            if identical {
500                let placeholder = ProvenanceSet::new(right.provenance.primary.clone());
501                let other = core::mem::replace(&mut right.provenance, placeholder);
502                left.provenance.extend(other);
503            }
504            identical
505        });
506        if self.edits.len() > self.max_edits {
507            return Err(EditError::new(
508                ErrorCode::PlanTooLarge,
509                "merged edit count exceeds the application limit",
510            ));
511        }
512        verify_ranges(
513            self.edits
514                .iter()
515                .map(|edit| (edit.start, edit.end, edit.order)),
516        )?;
517        self.output_size =
518            prepared_output_size(self.source.len(), &self.edits, self.max_output_size)?;
519        self.same_size = self
520            .edits
521            .iter()
522            .all(|edit| edit.end - edit.start == edit.after.len());
523        self.insert_runs = coalesce_insert_runs(&self.edits);
524        self.rendered = OnceLock::new();
525        Ok(self)
526    }
527
528    /// Returns whether an offset lies strictly inside replaced/deleted source.
529    #[must_use]
530    pub fn invalidates_offset(&self, offset: usize) -> bool {
531        self.edits
532            .iter()
533            .any(|edit| edit.start < offset && offset < edit.end)
534    }
535
536    /// Maps an original UTF-8 byte boundary into the resulting text.
537    #[must_use]
538    pub fn map_offset_forward(&self, offset: usize, bias: OffsetBias) -> Option<usize> {
539        if offset > self.source.len() || !self.source.is_char_boundary(offset) {
540            return None;
541        }
542        let mut delta = 0_i128;
543        for edit in &self.edits {
544            if offset < edit.start {
545                break;
546            }
547            let mapped_start = shifted(edit.start, delta)?;
548            if edit.start < offset && offset < edit.end {
549                return None;
550            }
551            if edit.start == edit.end && offset == edit.start {
552                if bias == OffsetBias::Left {
553                    return Some(mapped_start);
554                }
555                delta += i128::try_from(edit.after.len()).ok()?;
556                continue;
557            }
558            if offset == edit.start && edit.end > edit.start {
559                return Some(match bias {
560                    OffsetBias::Left => mapped_start,
561                    OffsetBias::Right => mapped_start.checked_add(edit.after.len())?,
562                });
563            }
564            if offset >= edit.end {
565                delta += i128::try_from(edit.after.len()).ok()?
566                    - i128::try_from(edit.end - edit.start).ok()?;
567            }
568        }
569        shifted(offset, delta)
570    }
571
572    #[must_use]
573    pub fn len(&self) -> usize {
574        self.edits.len()
575    }
576
577    /// Returns the immutable source size used to validate this plan.
578    #[must_use]
579    pub fn bytes_before(&self) -> usize {
580        self.source.len()
581    }
582
583    /// Returns the exact output size computed before application or streaming.
584    #[must_use]
585    pub fn bytes_after(&self) -> usize {
586        self.output_size
587    }
588
589    #[must_use]
590    pub fn is_empty(&self) -> bool {
591        self.edits.is_empty()
592    }
593}
594
595fn coalesce_insert_runs(edits: &[PreparedEdit]) -> Vec<InsertRun> {
596    let mut runs = Vec::new();
597    let mut retained_bytes = 0_usize;
598    let mut first = 0_usize;
599    while first < edits.len() {
600        let start = edits[first].start;
601        if edits[first].end != start {
602            first += 1;
603            continue;
604        }
605        let mut past_last = first + 1;
606        while past_last < edits.len()
607            && edits[past_last].start == start
608            && edits[past_last].end == start
609        {
610            past_last += 1;
611        }
612        if past_last - first > 1 {
613            let run_bytes = edits[first..past_last]
614                .iter()
615                .map(|edit| edit.after.len())
616                .sum();
617            if run_bytes <= MAX_COALESCED_INSERT_BYTES - retained_bytes {
618                let mut after = String::with_capacity(run_bytes);
619                for edit in &edits[first..past_last] {
620                    after.push_str(&edit.after);
621                }
622                runs.push(InsertRun {
623                    first_edit: first,
624                    past_last_edit: past_last,
625                    start,
626                    after,
627                });
628                retained_bytes += run_bytes;
629            }
630        }
631        first = past_last;
632    }
633    runs
634}
635
636fn shifted(offset: usize, delta: i128) -> Option<usize> {
637    usize::try_from(i128::try_from(offset).ok()?.checked_add(delta)?).ok()
638}
639
640#[cfg(test)]
641mod tests {
642    use crate::{application::ProvenanceSet, model::Provenance};
643
644    use super::{MAX_COALESCED_INSERT_BYTES, PreparedEdit, coalesce_insert_runs};
645
646    fn prepared(start: usize, end: usize, after: String, order: usize) -> PreparedEdit {
647        PreparedEdit {
648            start,
649            end,
650            order,
651            after,
652            provenance: ProvenanceSet::new(Provenance::new(Provenance::EXACT_LSP)),
653        }
654    }
655
656    #[test]
657    fn coalesced_insert_storage_has_one_global_hard_ceiling() {
658        let half = MAX_COALESCED_INSERT_BYTES / 2;
659        let edits = vec![
660            prepared(0, 0, "a".repeat(half / 2), 0),
661            prepared(0, 0, "b".repeat(half / 2), 1),
662            prepared(1, 2, "X".to_owned(), 2),
663            prepared(2, 2, "c".repeat(half / 2 + 1), 3),
664            prepared(2, 2, "d".repeat(half / 2), 4),
665        ];
666        let runs = coalesce_insert_runs(&edits);
667        assert_eq!(runs.len(), 1);
668        assert_eq!(runs[0].after.len(), half);
669        assert!(
670            runs.iter().map(|run| run.after.len()).sum::<usize>() <= MAX_COALESCED_INSERT_BYTES
671        );
672
673        let oversized = vec![
674            prepared(0, 0, "a".repeat(half + 1), 0),
675            prepared(0, 0, "b".repeat(half), 1),
676        ];
677        assert!(coalesce_insert_runs(&oversized).is_empty());
678    }
679}