Skip to main content

appcore_filemaker/
patch.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: patch.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded patch contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::{DocumentIr, ElementId, ElementIr, ErrorCode, FileMakerError, Length, Result, Style};
16
17/// One immutable ordered patch batch.
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19pub struct Patch {
20    /// Stable sequence used in provenance.
21    pub sequence: u64,
22    /// Operations applied atomically in order.
23    pub operations: Vec<PatchOperation>,
24}
25
26/// Supported runtime mutation operation.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "op", rename_all = "snake_case")]
29pub enum PatchOperation {
30    /// Set literal text.
31    SetText {
32        /// Target ID.
33        id: ElementId,
34        /// New text.
35        text: String,
36    },
37    /// Set visibility.
38    SetHidden {
39        /// Target ID.
40        id: ElementId,
41        /// Hidden state.
42        hidden: bool,
43    },
44    /// Overlay a validated runtime style layer before layout.
45    SetStyle {
46        /// Target ID.
47        id: ElementId,
48        /// Partial style to overlay.
49        style: Style,
50    },
51    /// Move an element.
52    Move {
53        /// Target ID.
54        id: ElementId,
55        /// New x.
56        x: Length,
57        /// New y.
58        y: Length,
59    },
60    /// Resize an element.
61    Resize {
62        /// Target ID.
63        id: ElementId,
64        /// New width.
65        width: Length,
66        /// New height.
67        height: Length,
68    },
69    /// Remove a node and its subtree.
70    Remove {
71        /// Target ID.
72        id: ElementId,
73    },
74    /// Add a child or root node.
75    Add {
76        /// Optional parent ID.
77        parent: Option<ElementId>,
78        /// New element.
79        element: ElementIr,
80    },
81    /// Clone a node with a new root ID.
82    Clone {
83        /// Source ID.
84        id: ElementId,
85        /// New root ID.
86        new_id: ElementId,
87    },
88    /// Replace a node while retaining its location in source order.
89    Replace {
90        /// Target ID.
91        id: ElementId,
92        /// Replacement.
93        element: ElementIr,
94    },
95}
96
97/// Transactional patch executor using copy-on-write rollback semantics.
98pub struct PatchTransaction<'a> {
99    document: &'a mut DocumentIr,
100    max_operations: usize,
101}
102
103impl<'a> PatchTransaction<'a> {
104    /// Starts a transaction over a document.
105    #[must_use]
106    pub fn new(document: &'a mut DocumentIr, max_operations: usize) -> Self {
107        Self {
108            document,
109            max_operations,
110        }
111    }
112
113    /// Applies the complete patch or restores the exact prior document.
114    pub fn apply(&mut self, patch: &Patch) -> Result<()> {
115        self.apply_with_rollback_snapshot(patch).map(drop)
116    }
117
118    /// Applies a patch and returns the owned rollback snapshot on success.
119    pub fn apply_with_rollback_snapshot(&mut self, patch: &Patch) -> Result<DocumentIr> {
120        if patch.operations.len() > self.max_operations {
121            return Err(FileMakerError::new(
122                ErrorCode::LimitExceeded,
123                "patch operation limit exceeded",
124            ));
125        }
126        let original = self.document.clone();
127        for operation in &patch.operations {
128            if let Err(error) = apply_operation(self.document, operation, patch.sequence) {
129                *self.document = original;
130                return Err(error);
131            }
132        }
133        if let Err(error) = Self::validate(self.document) {
134            *self.document = original;
135            return Err(error);
136        }
137        Ok(original)
138    }
139
140    /// Validates patch-sensitive document invariants without cloning it.
141    pub fn validate(document: &DocumentIr) -> Result<()> {
142        validate_document_ids(&document.elements)?;
143        crate::layout_page::validate_page_layer_ir(document)
144    }
145}
146
147fn apply_operation(
148    document: &mut DocumentIr,
149    operation: &PatchOperation,
150    sequence: u64,
151) -> Result<()> {
152    match operation {
153        PatchOperation::SetText { id, text } => mutate(document, id, sequence, |element| {
154            element.text = Some(text.clone());
155        }),
156        PatchOperation::SetHidden { id, hidden } => {
157            mutate(document, id, sequence, |element| element.hidden = *hidden)
158        }
159        PatchOperation::SetStyle { id, style } => {
160            style.validate()?;
161            mutate(document, id, sequence, |element| {
162                element.style.overlay(style)
163            })
164        }
165        PatchOperation::Move { id, x, y } => mutate(document, id, sequence, |element| {
166            element.geometry.x = Some(*x);
167            element.geometry.y = Some(*y);
168            element.geometry.align_x = None;
169            element.geometry.align_y = None;
170            element.geometry.anchors.clear();
171        }),
172        PatchOperation::Resize { id, width, height } => mutate(document, id, sequence, |element| {
173            element.geometry.width = Some(*width);
174            element.geometry.height = Some(*height);
175            element.geometry.constraints = crate::LayoutConstraints::default();
176        }),
177        PatchOperation::Remove { id } => remove(&mut document.elements, id),
178        PatchOperation::Add { parent, element } => {
179            add(document, parent.as_ref(), element.clone(), sequence)
180        }
181        PatchOperation::Clone { id, new_id } => clone_element(document, id, new_id, sequence),
182        PatchOperation::Replace { id, element } => {
183            replace(&mut document.elements, id, element.clone(), sequence)
184        }
185    }
186}
187
188fn mutate(
189    document: &mut DocumentIr,
190    id: &ElementId,
191    sequence: u64,
192    action: impl FnOnce(&mut ElementIr),
193) -> Result<()> {
194    let element = find_mut(&mut document.elements, id)
195        .ok_or_else(|| patch_error("patch target was not found"))?;
196    ensure_unlocked(element)?;
197    action(element);
198    element.provenance.patches.push(sequence);
199    Ok(())
200}
201
202fn add(
203    document: &mut DocumentIr,
204    parent: Option<&ElementId>,
205    mut element: ElementIr,
206    sequence: u64,
207) -> Result<()> {
208    if find(&document.elements, &element.id).is_some() {
209        return Err(patch_error("added element ID already exists"));
210    }
211    element.provenance.patches.push(sequence);
212    if let Some(parent) = parent {
213        let parent = find_mut(&mut document.elements, parent)
214            .ok_or_else(|| patch_error("add parent was not found"))?;
215        ensure_unlocked(parent)?;
216        parent.children.push(element);
217    } else {
218        document.elements.push(element);
219    }
220    Ok(())
221}
222
223fn clone_element(
224    document: &mut DocumentIr,
225    id: &ElementId,
226    new_id: &ElementId,
227    sequence: u64,
228) -> Result<()> {
229    if find(&document.elements, new_id).is_some() {
230        return Err(patch_error("clone ID already exists"));
231    }
232    let mut cloned = find(&document.elements, id)
233        .ok_or_else(|| patch_error("clone source was not found"))?
234        .clone();
235    ensure_unlocked(&cloned)?;
236    remap_clone_ids(&mut cloned, id.as_str(), new_id.as_str())?;
237    cloned.provenance.patches.push(sequence);
238    document.elements.push(cloned);
239    Ok(())
240}
241
242fn remove(elements: &mut Vec<ElementIr>, id: &ElementId) -> Result<()> {
243    let target = find(elements, id).ok_or_else(|| patch_error("remove target was not found"))?;
244    ensure_subtree_unlocked(target)?;
245    remove_unchecked(elements, id);
246    Ok(())
247}
248
249fn remove_unchecked(elements: &mut Vec<ElementIr>, id: &ElementId) -> bool {
250    if let Some(position) = elements.iter().position(|element| &element.id == id) {
251        elements.remove(position);
252        return true;
253    }
254    for element in elements {
255        if remove_unchecked(&mut element.children, id) {
256            return true;
257        }
258    }
259    false
260}
261
262fn replace(
263    elements: &mut [ElementIr],
264    id: &ElementId,
265    mut replacement: ElementIr,
266    sequence: u64,
267) -> Result<()> {
268    let target = find(elements, id).ok_or_else(|| patch_error("replace target was not found"))?;
269    ensure_subtree_unlocked(target)?;
270    if target.page_placement != replacement.page_placement {
271        return Err(patch_error("replace cannot change page-layer ownership"));
272    }
273    if replace_unchecked(elements, id, &mut replacement, sequence) {
274        Ok(())
275    } else {
276        Err(patch_error("replace target was not found"))
277    }
278}
279
280fn replace_unchecked(
281    elements: &mut [ElementIr],
282    id: &ElementId,
283    replacement: &mut ElementIr,
284    sequence: u64,
285) -> bool {
286    for element in elements {
287        if &element.id == id {
288            replacement.provenance.patches.push(sequence);
289            *element = replacement.clone();
290            return true;
291        }
292        if replace_unchecked(&mut element.children, id, replacement, sequence) {
293            return true;
294        }
295    }
296    false
297}
298
299fn find<'a>(elements: &'a [ElementIr], id: &ElementId) -> Option<&'a ElementIr> {
300    for element in elements {
301        if &element.id == id {
302            return Some(element);
303        }
304        if let Some(found) = find(&element.children, id) {
305            return Some(found);
306        }
307    }
308    None
309}
310
311fn find_mut<'a>(elements: &'a mut [ElementIr], id: &ElementId) -> Option<&'a mut ElementIr> {
312    for element in elements {
313        if &element.id == id {
314            return Some(element);
315        }
316        if let Some(found) = find_mut(&mut element.children, id) {
317            return Some(found);
318        }
319    }
320    None
321}
322
323fn ensure_unlocked(element: &ElementIr) -> Result<()> {
324    if element.locked {
325        Err(
326            FileMakerError::new(ErrorCode::PatchLocked, "element is locked")
327                .at(element.id.as_str()),
328        )
329    } else {
330        Ok(())
331    }
332}
333
334fn ensure_subtree_unlocked(root: &ElementIr) -> Result<()> {
335    let mut stack = vec![root];
336    while let Some(element) = stack.pop() {
337        ensure_unlocked(element)?;
338        stack.extend(element.children.iter().rev());
339    }
340    Ok(())
341}
342
343fn remap_clone_ids(element: &mut ElementIr, old_root: &str, new_root: &str) -> Result<()> {
344    let current = element.id.as_str();
345    let suffix = current.strip_prefix(old_root).unwrap_or(current);
346    element.id = ElementId::new(format!("{new_root}{suffix}"))?;
347    for child in &mut element.children {
348        remap_clone_ids(child, old_root, new_root)?;
349    }
350    Ok(())
351}
352
353fn validate_document_ids(elements: &[ElementIr]) -> Result<()> {
354    let mut ids = std::collections::BTreeSet::new();
355    let mut stack: Vec<&ElementIr> = elements.iter().collect();
356    while let Some(element) = stack.pop() {
357        if !ids.insert(element.id.as_str()) {
358            return Err(patch_error(format!(
359                "patch produced duplicate element ID `{}`",
360                element.id.as_str()
361            )));
362        }
363        stack.extend(&element.children);
364    }
365    Ok(())
366}
367
368fn patch_error(message: impl Into<String>) -> FileMakerError {
369    FileMakerError::new(ErrorCode::PatchInvalid, message)
370}
371
372#[cfg(test)]
373mod operation_log_tests {
374    use std::collections::BTreeMap;
375
376    use super::*;
377    use crate::{Compiler, DataValue, OperationLog};
378
379    fn document() -> DocumentIr {
380        let yaml = br"filemaker: '1.0'
381model: canvas
382id: log
383page: { width: 10pt, height: 10pt }
384elements: [{ id: node, type: rect, width: 2pt, height: 2pt }]
385";
386        let compiler = Compiler::builder().build().unwrap();
387        let template = compiler.compile_template_yaml(yaml).unwrap();
388        compiler
389            .bind(&template, &DataValue::Object(BTreeMap::new()), &[])
390            .unwrap()
391    }
392
393    fn visibility_patch(sequence: u64, hidden: bool) -> Patch {
394        Patch {
395            sequence,
396            operations: vec![PatchOperation::SetHidden {
397                id: ElementId::new("node").unwrap(),
398                hidden,
399            }],
400        }
401    }
402
403    #[test]
404    fn successful_patches_can_be_undone_and_redone() {
405        let mut document = document();
406        let patch = visibility_patch(1, true);
407        let mut log = OperationLog::new(2).unwrap();
408        log.apply(&mut document, &patch, 4).unwrap();
409        assert!(document.elements[0].hidden);
410        log.undo(&mut document).unwrap();
411        assert!(!document.elements[0].hidden);
412        log.redo(&mut document).unwrap();
413        assert!(document.elements[0].hidden);
414    }
415
416    #[test]
417    fn rejects_a_snapshot_before_mutating_the_document() {
418        let mut document = document();
419        let original = document.clone();
420        let mut log = OperationLog::new_bounded(2, 1).unwrap();
421        let error = log
422            .apply(&mut document, &visibility_patch(1, true), 4)
423            .unwrap_err();
424        assert_eq!(error.code(), ErrorCode::LimitExceeded);
425        assert_eq!(document, original);
426        assert_eq!(log.used_bytes(), 0);
427    }
428
429    #[test]
430    fn evicts_old_snapshots_to_honor_the_aggregate_byte_budget() {
431        let mut document = document();
432        let original_bytes = crate::memory::serialized_size(&document).unwrap();
433        let mut changed = document.clone();
434        PatchTransaction::new(&mut changed, 1)
435            .apply(&visibility_patch(1, true))
436            .unwrap();
437        let changed_bytes = crate::memory::serialized_size(&changed).unwrap();
438        let mut log = OperationLog::new_bounded(4, original_bytes.max(changed_bytes)).unwrap();
439        log.apply(&mut document, &visibility_patch(1, true), 1)
440            .unwrap();
441        log.apply(&mut document, &visibility_patch(2, false), 1)
442            .unwrap();
443        assert_eq!(log.undo_len(), 1);
444        assert!(log.used_bytes() <= log.max_bytes());
445    }
446}