Skip to main content

lex_vcs/
apply.rs

1//! The apply gate. Validates an operation's parents against a known
2//! branch head, then persists it via [`OpLog`]. Issue #129 keeps this
3//! narrow: no type checking, no effect verification — those are #130.
4
5use crate::op_log::OpLog;
6use crate::operation::{OpId, Operation, OperationRecord, StageTransition};
7use std::io;
8
9#[derive(Debug)]
10pub struct NewHead {
11    pub op_id: OpId,
12    pub record: OperationRecord,
13}
14
15#[derive(Debug, thiserror::Error)]
16pub enum ApplyError {
17    #[error("stale parent: branch head is {expected:?} but op's parents are {op_parents:?}")]
18    StaleParent {
19        expected: Option<OpId>,
20        op_parents: Vec<OpId>,
21    },
22    #[error("merge op references unknown second parent {0}")]
23    UnknownMergeParent(OpId),
24    #[error(transparent)]
25    Persist(#[from] io::Error),
26}
27
28/// Apply an operation against a branch head and persist it.
29///
30/// Validates parents:
31/// - If `op.parents.is_empty()`: `head_op` must be `None` (genesis op
32///   on an empty branch).
33/// - If `op.parents.len() == 1`: that parent must equal `head_op`.
34/// - If `op.parents.len() == 2`: one parent must equal `head_op`, and
35///   the other must already exist in the log (a merge op's
36///   second-parent ancestry must be reachable).
37/// - All other arities are rejected as `StaleParent`.
38pub fn apply(
39    op_log: &OpLog,
40    head_op: Option<&OpId>,
41    op: Operation,
42    transition: StageTransition,
43) -> Result<NewHead, ApplyError> {
44    match (op.parents.len(), head_op) {
45        (0, None) => {}
46        (1, Some(h)) if op.parents[0] == *h => {}
47        (2, Some(h)) => {
48            if op.parents[0] == op.parents[1] {
49                return Err(ApplyError::StaleParent {
50                    expected: head_op.cloned(),
51                    op_parents: op.parents.clone(),
52                });
53            }
54            if op.parents[0] != *h && op.parents[1] != *h {
55                return Err(ApplyError::StaleParent {
56                    expected: head_op.cloned(),
57                    op_parents: op.parents.clone(),
58                });
59            }
60            // The non-head parent must exist in the log.
61            let other = if op.parents[0] == *h { &op.parents[1] } else { &op.parents[0] };
62            if op_log.get(other)?.is_none() {
63                return Err(ApplyError::UnknownMergeParent(other.clone()));
64            }
65        }
66        _ => {
67            return Err(ApplyError::StaleParent {
68                expected: head_op.cloned(),
69                op_parents: op.parents.clone(),
70            });
71        }
72    }
73
74    let record = OperationRecord::new(op, transition);
75    op_log.put(&record)?;
76    Ok(NewHead { op_id: record.op_id.clone(), record })
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::operation::{OperationKind, StageTransition};
83    use std::collections::BTreeSet;
84
85    fn add_fac() -> (Operation, StageTransition) {
86        let op = Operation::new(
87            OperationKind::AddFunction {
88                sig_id: "fac".into(),
89                stage_id: "s1".into(),
90                effects: BTreeSet::new(),
91                budget_cost: None,
92                in_file: None,
93            },
94            [],
95        );
96        let t = StageTransition::Create {
97            sig_id: "fac".into(),
98            stage_id: "s1".into(),
99        };
100        (op, t)
101    }
102
103    #[test]
104    fn parentless_op_against_empty_head_succeeds() {
105        let tmp = tempfile::tempdir().unwrap();
106        let log = OpLog::open(tmp.path()).unwrap();
107        let (op, t) = add_fac();
108        let head = apply(&log, None, op, t).unwrap();
109        assert!(log.get(&head.op_id).unwrap().is_some());
110    }
111
112    #[test]
113    fn parentless_op_against_non_empty_head_is_stale() {
114        let tmp = tempfile::tempdir().unwrap();
115        let log = OpLog::open(tmp.path()).unwrap();
116        let (op1, t1) = add_fac();
117        let head1 = apply(&log, None, op1, t1).unwrap();
118        let (op2, t2) = add_fac(); // parentless again
119        let err = apply(&log, Some(&head1.op_id), op2, t2).unwrap_err();
120        match err {
121            ApplyError::StaleParent { expected, op_parents } => {
122                assert_eq!(expected.as_deref(), Some(head1.op_id.as_str()));
123                assert!(op_parents.is_empty());
124            }
125            other => panic!("expected StaleParent, got {other:?}"),
126        }
127    }
128
129    #[test]
130    fn single_parent_matching_head_succeeds() {
131        let tmp = tempfile::tempdir().unwrap();
132        let log = OpLog::open(tmp.path()).unwrap();
133        let (op1, t1) = add_fac();
134        let head1 = apply(&log, None, op1, t1).unwrap();
135        let modify = Operation::new(
136            OperationKind::ModifyBody {
137                sig_id: "fac".into(),
138                from_stage_id: "s1".into(),
139                to_stage_id: "s2".into(),
140                from_budget: None,
141                to_budget: None,
142                to_sig_id: None,
143            },
144            [head1.op_id.clone()],
145        );
146        let t = StageTransition::Replace {
147            sig_id: "fac".into(),
148            from: "s1".into(),
149            to: "s2".into(),
150        };
151        let head2 = apply(&log, Some(&head1.op_id), modify, t).unwrap();
152        assert_ne!(head2.op_id, head1.op_id);
153    }
154
155    #[test]
156    fn single_parent_not_matching_head_is_stale() {
157        let tmp = tempfile::tempdir().unwrap();
158        let log = OpLog::open(tmp.path()).unwrap();
159        let (op1, t1) = add_fac();
160        let head1 = apply(&log, None, op1, t1).unwrap();
161        let bogus = Operation::new(
162            OperationKind::ModifyBody {
163                sig_id: "fac".into(),
164                from_stage_id: "s1".into(),
165                to_stage_id: "s2".into(),
166                from_budget: None,
167                to_budget: None,
168                to_sig_id: None,
169            },
170            ["someone-else".into()],
171        );
172        let t = StageTransition::Replace {
173            sig_id: "fac".into(),
174            from: "s1".into(),
175            to: "s2".into(),
176        };
177        let err = apply(&log, Some(&head1.op_id), bogus, t).unwrap_err();
178        match err {
179            ApplyError::StaleParent { expected, op_parents } => {
180                assert_eq!(expected.as_deref(), Some(head1.op_id.as_str()));
181                assert_eq!(op_parents, vec!["someone-else".to_string()]);
182            }
183            other => panic!("expected StaleParent, got {other:?}"),
184        }
185    }
186
187    #[test]
188    fn merge_op_with_known_second_parent_succeeds() {
189        let tmp = tempfile::tempdir().unwrap();
190        let log = OpLog::open(tmp.path()).unwrap();
191        let (op_a, t_a) = add_fac();
192        let head_a = apply(&log, None, op_a, t_a).unwrap();
193        let other = Operation::new(
194            OperationKind::AddFunction {
195                sig_id: "double".into(),
196                stage_id: "d1".into(),
197                effects: BTreeSet::new(),
198                budget_cost: None,
199                in_file: None,
200            },
201            [],
202        );
203        let head_b = apply(&log, None, other, StageTransition::Create {
204            sig_id: "double".into(), stage_id: "d1".into(),
205        }).unwrap();
206        // Merge op: parents = [head_a, head_b].
207        let merge = Operation::new(
208            OperationKind::Merge { resolved: 1 },
209            [head_a.op_id.clone(), head_b.op_id.clone()],
210        );
211        let t = StageTransition::Merge {
212            entries: std::iter::once(("double".to_string(), Some("d1".to_string())))
213                .collect(),
214        };
215        let merged = apply(&log, Some(&head_a.op_id), merge, t).unwrap();
216        assert!(log.get(&merged.op_id).unwrap().is_some());
217    }
218
219    #[test]
220    fn merge_op_with_unknown_second_parent_fails() {
221        let tmp = tempfile::tempdir().unwrap();
222        let log = OpLog::open(tmp.path()).unwrap();
223        let (op_a, t_a) = add_fac();
224        let head_a = apply(&log, None, op_a, t_a).unwrap();
225        let merge = Operation::new(
226            OperationKind::Merge { resolved: 0 },
227            [head_a.op_id.clone(), "ghost".into()],
228        );
229        let t = StageTransition::Merge { entries: Default::default() };
230        let err = apply(&log, Some(&head_a.op_id), merge, t).unwrap_err();
231        match err {
232            ApplyError::UnknownMergeParent(id) => {
233                assert_eq!(id, "ghost");
234            }
235            other => panic!("expected UnknownMergeParent, got {other:?}"),
236        }
237    }
238
239    #[test]
240    fn three_parent_op_is_stale() {
241        // Catch-all arm: any arity > 2 is rejected.
242        let tmp = tempfile::tempdir().unwrap();
243        let log = OpLog::open(tmp.path()).unwrap();
244        let (op_a, t_a) = add_fac();
245        let head_a = apply(&log, None, op_a, t_a).unwrap();
246
247        // Hand-construct an Operation with three parents (Operation::new
248        // dedups but accepts arbitrary count).
249        let weird = Operation::new(
250            OperationKind::ModifyBody {
251                sig_id: "fac".into(),
252                from_stage_id: "s1".into(),
253                to_stage_id: "s2".into(),
254                from_budget: None,
255                to_budget: None,
256                to_sig_id: None,
257            },
258            [head_a.op_id.clone(), "p2".into(), "p3".into()],
259        );
260        let t = StageTransition::Replace {
261            sig_id: "fac".into(), from: "s1".into(), to: "s2".into(),
262        };
263        let err = apply(&log, Some(&head_a.op_id), weird, t).unwrap_err();
264        assert!(matches!(err, ApplyError::StaleParent { .. }));
265    }
266
267    #[test]
268    fn single_parent_against_empty_head_is_stale() {
269        // Catch-all arm: 1 parent + None head is rejected.
270        let tmp = tempfile::tempdir().unwrap();
271        let log = OpLog::open(tmp.path()).unwrap();
272        let modify = Operation::new(
273            OperationKind::ModifyBody {
274                sig_id: "fac".into(),
275                from_stage_id: "s1".into(),
276                to_stage_id: "s2".into(),
277                from_budget: None,
278                to_budget: None,
279                to_sig_id: None,
280            },
281            ["claimed-parent".into()],
282        );
283        let t = StageTransition::Replace {
284            sig_id: "fac".into(), from: "s1".into(), to: "s2".into(),
285        };
286        let err = apply(&log, None, modify, t).unwrap_err();
287        match err {
288            ApplyError::StaleParent { expected, op_parents } => {
289                assert_eq!(expected, None);
290                assert_eq!(op_parents, vec!["claimed-parent".to_string()]);
291            }
292            other => panic!("expected StaleParent, got {other:?}"),
293        }
294    }
295
296    #[test]
297    fn self_merge_is_stale() {
298        // Direct deserialization could produce parents = [h, h] which
299        // bypasses Operation::new's dedup. The gate must still reject.
300        let tmp = tempfile::tempdir().unwrap();
301        let log = OpLog::open(tmp.path()).unwrap();
302        let (op_a, t_a) = add_fac();
303        let head_a = apply(&log, None, op_a, t_a).unwrap();
304
305        // Construct an Operation with two equal parents *without* going
306        // through `new` (which dedups). Use serde_json round-trip.
307        let json = serde_json::json!({
308            "op": "merge",
309            "resolved": 0,
310            "parents": [head_a.op_id.clone(), head_a.op_id.clone()],
311        });
312        let weird: Operation = serde_json::from_value(json).unwrap();
313        assert_eq!(weird.parents.len(), 2,
314            "round-trip should preserve duplicates if Operation deserialization doesn't dedup");
315        let t = StageTransition::Merge { entries: Default::default() };
316        let err = apply(&log, Some(&head_a.op_id), weird, t).unwrap_err();
317        assert!(matches!(err, ApplyError::StaleParent { .. }));
318    }
319}