1use 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
28pub 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 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 },
93 [],
94 );
95 let t = StageTransition::Create {
96 sig_id: "fac".into(),
97 stage_id: "s1".into(),
98 };
99 (op, t)
100 }
101
102 #[test]
103 fn parentless_op_against_empty_head_succeeds() {
104 let tmp = tempfile::tempdir().unwrap();
105 let log = OpLog::open(tmp.path()).unwrap();
106 let (op, t) = add_fac();
107 let head = apply(&log, None, op, t).unwrap();
108 assert!(log.get(&head.op_id).unwrap().is_some());
109 }
110
111 #[test]
112 fn parentless_op_against_non_empty_head_is_stale() {
113 let tmp = tempfile::tempdir().unwrap();
114 let log = OpLog::open(tmp.path()).unwrap();
115 let (op1, t1) = add_fac();
116 let head1 = apply(&log, None, op1, t1).unwrap();
117 let (op2, t2) = add_fac(); let err = apply(&log, Some(&head1.op_id), op2, t2).unwrap_err();
119 match err {
120 ApplyError::StaleParent { expected, op_parents } => {
121 assert_eq!(expected.as_deref(), Some(head1.op_id.as_str()));
122 assert!(op_parents.is_empty());
123 }
124 other => panic!("expected StaleParent, got {other:?}"),
125 }
126 }
127
128 #[test]
129 fn single_parent_matching_head_succeeds() {
130 let tmp = tempfile::tempdir().unwrap();
131 let log = OpLog::open(tmp.path()).unwrap();
132 let (op1, t1) = add_fac();
133 let head1 = apply(&log, None, op1, t1).unwrap();
134 let modify = Operation::new(
135 OperationKind::ModifyBody {
136 sig_id: "fac".into(),
137 from_stage_id: "s1".into(),
138 to_stage_id: "s2".into(),
139 from_budget: None,
140 to_budget: None,
141 },
142 [head1.op_id.clone()],
143 );
144 let t = StageTransition::Replace {
145 sig_id: "fac".into(),
146 from: "s1".into(),
147 to: "s2".into(),
148 };
149 let head2 = apply(&log, Some(&head1.op_id), modify, t).unwrap();
150 assert_ne!(head2.op_id, head1.op_id);
151 }
152
153 #[test]
154 fn single_parent_not_matching_head_is_stale() {
155 let tmp = tempfile::tempdir().unwrap();
156 let log = OpLog::open(tmp.path()).unwrap();
157 let (op1, t1) = add_fac();
158 let head1 = apply(&log, None, op1, t1).unwrap();
159 let bogus = Operation::new(
160 OperationKind::ModifyBody {
161 sig_id: "fac".into(),
162 from_stage_id: "s1".into(),
163 to_stage_id: "s2".into(),
164 from_budget: None,
165 to_budget: None,
166 },
167 ["someone-else".into()],
168 );
169 let t = StageTransition::Replace {
170 sig_id: "fac".into(),
171 from: "s1".into(),
172 to: "s2".into(),
173 };
174 let err = apply(&log, Some(&head1.op_id), bogus, t).unwrap_err();
175 match err {
176 ApplyError::StaleParent { expected, op_parents } => {
177 assert_eq!(expected.as_deref(), Some(head1.op_id.as_str()));
178 assert_eq!(op_parents, vec!["someone-else".to_string()]);
179 }
180 other => panic!("expected StaleParent, got {other:?}"),
181 }
182 }
183
184 #[test]
185 fn merge_op_with_known_second_parent_succeeds() {
186 let tmp = tempfile::tempdir().unwrap();
187 let log = OpLog::open(tmp.path()).unwrap();
188 let (op_a, t_a) = add_fac();
189 let head_a = apply(&log, None, op_a, t_a).unwrap();
190 let other = Operation::new(
191 OperationKind::AddFunction {
192 sig_id: "double".into(),
193 stage_id: "d1".into(),
194 effects: BTreeSet::new(),
195 budget_cost: None,
196 },
197 [],
198 );
199 let head_b = apply(&log, None, other, StageTransition::Create {
200 sig_id: "double".into(), stage_id: "d1".into(),
201 }).unwrap();
202 let merge = Operation::new(
204 OperationKind::Merge { resolved: 1 },
205 [head_a.op_id.clone(), head_b.op_id.clone()],
206 );
207 let t = StageTransition::Merge {
208 entries: std::iter::once(("double".to_string(), Some("d1".to_string())))
209 .collect(),
210 };
211 let merged = apply(&log, Some(&head_a.op_id), merge, t).unwrap();
212 assert!(log.get(&merged.op_id).unwrap().is_some());
213 }
214
215 #[test]
216 fn merge_op_with_unknown_second_parent_fails() {
217 let tmp = tempfile::tempdir().unwrap();
218 let log = OpLog::open(tmp.path()).unwrap();
219 let (op_a, t_a) = add_fac();
220 let head_a = apply(&log, None, op_a, t_a).unwrap();
221 let merge = Operation::new(
222 OperationKind::Merge { resolved: 0 },
223 [head_a.op_id.clone(), "ghost".into()],
224 );
225 let t = StageTransition::Merge { entries: Default::default() };
226 let err = apply(&log, Some(&head_a.op_id), merge, t).unwrap_err();
227 match err {
228 ApplyError::UnknownMergeParent(id) => {
229 assert_eq!(id, "ghost");
230 }
231 other => panic!("expected UnknownMergeParent, got {other:?}"),
232 }
233 }
234
235 #[test]
236 fn three_parent_op_is_stale() {
237 let tmp = tempfile::tempdir().unwrap();
239 let log = OpLog::open(tmp.path()).unwrap();
240 let (op_a, t_a) = add_fac();
241 let head_a = apply(&log, None, op_a, t_a).unwrap();
242
243 let weird = Operation::new(
246 OperationKind::ModifyBody {
247 sig_id: "fac".into(),
248 from_stage_id: "s1".into(),
249 to_stage_id: "s2".into(),
250 from_budget: None,
251 to_budget: None,
252 },
253 [head_a.op_id.clone(), "p2".into(), "p3".into()],
254 );
255 let t = StageTransition::Replace {
256 sig_id: "fac".into(), from: "s1".into(), to: "s2".into(),
257 };
258 let err = apply(&log, Some(&head_a.op_id), weird, t).unwrap_err();
259 assert!(matches!(err, ApplyError::StaleParent { .. }));
260 }
261
262 #[test]
263 fn single_parent_against_empty_head_is_stale() {
264 let tmp = tempfile::tempdir().unwrap();
266 let log = OpLog::open(tmp.path()).unwrap();
267 let modify = Operation::new(
268 OperationKind::ModifyBody {
269 sig_id: "fac".into(),
270 from_stage_id: "s1".into(),
271 to_stage_id: "s2".into(),
272 from_budget: None,
273 to_budget: None,
274 },
275 ["claimed-parent".into()],
276 );
277 let t = StageTransition::Replace {
278 sig_id: "fac".into(), from: "s1".into(), to: "s2".into(),
279 };
280 let err = apply(&log, None, modify, t).unwrap_err();
281 match err {
282 ApplyError::StaleParent { expected, op_parents } => {
283 assert_eq!(expected, None);
284 assert_eq!(op_parents, vec!["claimed-parent".to_string()]);
285 }
286 other => panic!("expected StaleParent, got {other:?}"),
287 }
288 }
289
290 #[test]
291 fn self_merge_is_stale() {
292 let tmp = tempfile::tempdir().unwrap();
295 let log = OpLog::open(tmp.path()).unwrap();
296 let (op_a, t_a) = add_fac();
297 let head_a = apply(&log, None, op_a, t_a).unwrap();
298
299 let json = serde_json::json!({
302 "op": "merge",
303 "resolved": 0,
304 "parents": [head_a.op_id.clone(), head_a.op_id.clone()],
305 });
306 let weird: Operation = serde_json::from_value(json).unwrap();
307 assert_eq!(weird.parents.len(), 2,
308 "round-trip should preserve duplicates if Operation deserialization doesn't dedup");
309 let t = StageTransition::Merge { entries: Default::default() };
310 let err = apply(&log, Some(&head_a.op_id), weird, t).unwrap_err();
311 assert!(matches!(err, ApplyError::StaleParent { .. }));
312 }
313}