1mod display;
4mod merge_plan;
5
6use std::collections::BTreeSet;
7
8use prikk_error::{PrikkError, Result};
9use prikk_object::{BlockKind, BlockPayload, ObjectId, ObjectType, RefStatePayload};
10
11pub use display::{
12 MergeEvidenceDisplay, MergeEvidenceDisplayItem, MergeEvidenceDisplayOperation,
13 MergeEvidenceDisplaySelector,
14};
15pub use merge_plan::MergePlanDisplay;
16
17use crate::lifecycle_cache::replay_derived_state;
18use crate::object_store::{ObjectReadSnapshot, ObjectReader};
19use crate::patch_algebra::{EvidenceScope, StorePatchAlgebraEvidence, analyze_merge_evidence};
20use crate::patch_replay::decode::{DecodedPatchOperation, decode_patch_operations};
21use crate::received::read_received_pointer;
22use crate::refs::RefStore;
23use crate::trust::{MaintainerTrustPolicy, verify_trusted_publication_envelope};
24use crate::{RepositoryLayout, validate_local_branch_ref};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MergeEvidenceTarget {
29 Block(ObjectId),
31 Ref(String),
33 ReceivedRef(String),
36}
37
38pub fn prepare_merge_evidence(
40 layout: &RepositoryLayout,
41 baseline_block_id: ObjectId,
42 left_target: MergeEvidenceTarget,
43 right_target: MergeEvidenceTarget,
44) -> Result<MergeEvidenceDisplay> {
45 let object_store = ObjectReadSnapshot::open(layout)?;
50 let baseline_horizon = lineage_horizon(&object_store, baseline_block_id)?;
51 let replay = replay_derived_state(&object_store, baseline_block_id, baseline_horizon)?;
52 let evidence =
53 StorePatchAlgebraEvidence::from_replay_derived(&object_store, baseline_horizon, replay)
54 .map_err(|err| PrikkError::Integrity(format!("merge evidence baseline: {err:?}")))?;
55 let left_selector = resolve_target(layout, &object_store, left_target)?;
56 let right_selector = resolve_target(layout, &object_store, right_target)?;
57 let left_operations = candidate_sequence(
58 &object_store,
59 baseline_block_id,
60 left_selector.target_block_id,
61 )?;
62 let right_operations = candidate_sequence(
63 &object_store,
64 baseline_block_id,
65 right_selector.target_block_id,
66 )?;
67 let report = analyze_merge_evidence(
68 baseline_block_id,
69 Some(baseline_horizon),
70 evidence.baseline_state(),
71 &evidence,
72 EvidenceScope::SealedCandidateRequired,
73 &left_operations,
74 &right_operations,
75 );
76 Ok(MergeEvidenceDisplay::from_report(
77 report,
78 left_selector,
79 right_selector,
80 ))
81}
82
83pub fn prepare_merge_plan(
85 layout: &RepositoryLayout,
86 baseline_block_id: ObjectId,
87 left_target: MergeEvidenceTarget,
88 right_target: MergeEvidenceTarget,
89) -> Result<MergePlanDisplay> {
90 let evidence = prepare_merge_evidence(layout, baseline_block_id, left_target, right_target)?;
91 Ok(MergePlanDisplay::from_evidence(evidence))
92}
93
94fn resolve_target(
95 layout: &RepositoryLayout,
96 object_store: &impl ObjectReader,
97 target: MergeEvidenceTarget,
98) -> Result<MergeEvidenceDisplaySelector> {
99 match target {
100 MergeEvidenceTarget::Block(block_id) => {
101 read_block(object_store, block_id)?;
102 Ok(MergeEvidenceDisplaySelector {
103 selector: format!("block {block_id}"),
104 target_block_id: block_id,
105 })
106 }
107 MergeEvidenceTarget::Ref(ref_name) => {
108 let ref_name = validate_local_branch_ref(&ref_name)?;
109 let ref_store = RefStore::new(layout.clone());
110 let ref_state_id = ref_store
111 .read_current_ref_state_id(&ref_name)?
112 .ok_or_else(|| PrikkError::Integrity(format!("ref {ref_name} is not published")))?;
113 let envelope = object_store
114 .read_typed(ref_state_id, ObjectType::RefState)?
115 .ok_or_else(|| {
116 PrikkError::Integrity(format!("ref {ref_name} points to missing RefState"))
117 })?;
118 let ref_state = RefStatePayload::decode_canonical(
119 &envelope.canonical_payload,
120 envelope.schema_version,
121 )?;
122 if ref_state.ref_name != ref_name {
123 return Err(PrikkError::Integrity(format!(
124 "RefState name mismatch: expected {ref_name}, got {}",
125 ref_state.ref_name
126 )));
127 }
128 read_block(object_store, ref_state.target_object_id)?;
129 Ok(MergeEvidenceDisplaySelector {
130 selector: format!("ref {ref_name}"),
131 target_block_id: ref_state.target_object_id,
132 })
133 }
134 MergeEvidenceTarget::ReceivedRef(ref_name) => {
135 let pointer = read_received_pointer(layout, &ref_name)?.ok_or_else(|| {
136 PrikkError::Integrity(format!("received ref {ref_name} does not exist"))
137 })?;
138 let envelope = object_store
139 .read_typed(pointer.ref_state_id, ObjectType::RefState)?
140 .ok_or_else(|| {
141 PrikkError::Integrity(format!(
142 "received ref {ref_name} points to missing RefState"
143 ))
144 })?;
145 let ref_state = RefStatePayload::decode_canonical(
146 &envelope.canonical_payload,
147 envelope.schema_version,
148 )?;
149 read_block(object_store, ref_state.target_object_id)?;
155 Ok(MergeEvidenceDisplaySelector {
156 selector: format!("received ref {ref_name}"),
157 target_block_id: ref_state.target_object_id,
158 })
159 }
160 }
161}
162
163fn lineage_horizon(object_store: &impl ObjectReader, baseline: ObjectId) -> Result<ObjectId> {
164 let mut visited = BTreeSet::new();
165 let mut current = baseline;
166 loop {
167 if !visited.insert(current) {
168 return Err(PrikkError::Integrity(format!(
169 "block parent chain contains a cycle at {current}"
170 )));
171 }
172 let block = read_block(object_store, current)?;
173 match mainline_or_sole_parent(&block) {
176 Some(None) => return Ok(current),
177 Some(Some(parent)) => current = parent,
178 None => {
179 return Err(PrikkError::UnsupportedObjectType(format!(
180 "merge evidence requires a single-parent baseline lineage; block {current} has {} parents",
181 block.parent_block_ids.len()
182 )));
183 }
184 }
185 }
186}
187
188fn mainline_or_sole_parent(block: &BlockPayload) -> Option<Option<ObjectId>> {
193 if block.kind == BlockKind::Merge {
194 let mainline = block.mainline_parent_id?;
195 if !block.parent_block_ids.contains(&mainline) {
196 return None;
197 }
198 return Some(Some(mainline));
199 }
200 match block.parent_block_ids.as_slice() {
201 [] => Some(None),
202 [parent] => Some(Some(*parent)),
203 _ => None,
204 }
205}
206
207pub(crate) fn ancestors_inclusive(
216 object_store: &impl ObjectReader,
217 start: ObjectId,
218) -> Result<std::collections::BTreeMap<ObjectId, BlockPayload>> {
219 let mut ancestors = std::collections::BTreeMap::new();
220 let mut stack = vec![start];
221 while let Some(current) = stack.pop() {
222 if ancestors.contains_key(¤t) {
223 continue;
224 }
225 let block = read_block(object_store, current)?;
226 crate::validate_block_v2_shape(&block)?;
227 stack.extend(block.parent_block_ids.iter().copied());
228 ancestors.insert(current, block);
229 }
230 Ok(ancestors)
231}
232
233fn topological_order(
239 new_ids: &BTreeSet<ObjectId>,
240 ancestors: &std::collections::BTreeMap<ObjectId, BlockPayload>,
241) -> Result<Vec<ObjectId>> {
242 let mut remaining_parents = std::collections::BTreeMap::new();
243 let mut children: std::collections::BTreeMap<ObjectId, Vec<ObjectId>> =
244 std::collections::BTreeMap::new();
245 for id in new_ids {
246 let Some(block) = ancestors.get(id) else {
247 return Err(PrikkError::Integrity(format!(
248 "candidate block set references untracked block {id}"
249 )));
250 };
251 let count = block
252 .parent_block_ids
253 .iter()
254 .filter(|parent| new_ids.contains(parent))
255 .count();
256 remaining_parents.insert(*id, count);
257 for parent in &block.parent_block_ids {
258 if new_ids.contains(parent) {
259 children.entry(*parent).or_default().push(*id);
260 }
261 }
262 }
263 let mut ready: Vec<ObjectId> = remaining_parents
264 .iter()
265 .filter(|(_, count)| **count == 0)
266 .map(|(id, _)| *id)
267 .collect();
268 ready.sort();
269 let mut queue: std::collections::VecDeque<ObjectId> = ready.into();
270 let mut order = Vec::with_capacity(new_ids.len());
271 while let Some(id) = queue.pop_front() {
272 order.push(id);
273 for child in children.get(&id).into_iter().flatten() {
274 let Some(entry) = remaining_parents.get_mut(child) else {
275 return Err(PrikkError::Integrity(
276 "candidate block set topological sort lost a tracked child".to_string(),
277 ));
278 };
279 *entry -= 1;
280 if *entry == 0 {
281 queue.push_back(*child);
282 }
283 }
284 }
285 if order.len() != new_ids.len() {
286 return Err(PrikkError::Integrity(
287 "candidate block set contains a cycle".to_string(),
288 ));
289 }
290 Ok(order)
291}
292
293fn candidate_blocks(
302 object_store: &impl ObjectReader,
303 baseline: ObjectId,
304 target: ObjectId,
305) -> Result<Vec<(ObjectId, BlockPayload)>> {
306 let target_ancestors = ancestors_inclusive(object_store, target)?;
307 if !target_ancestors.contains_key(&baseline) {
308 return Err(PrikkError::Integrity(format!(
309 "baseline Block {baseline} is not an ancestor of target Block {target}"
310 )));
311 }
312 let baseline_ancestors = ancestors_inclusive(object_store, baseline)?;
313 let new_ids: BTreeSet<ObjectId> = target_ancestors
314 .keys()
315 .filter(|id| !baseline_ancestors.contains_key(id))
316 .copied()
317 .collect();
318 topological_order(&new_ids, &target_ancestors)?
319 .into_iter()
320 .map(|id| {
321 let payload = target_ancestors.get(&id).cloned().ok_or_else(|| {
322 PrikkError::Integrity(format!("missing Block {id} in candidate set"))
323 })?;
324 Ok((id, payload))
325 })
326 .collect()
327}
328
329fn baseline_reachable_patch_ids(
335 object_store: &impl ObjectReader,
336 baseline: ObjectId,
337) -> Result<BTreeSet<ObjectId>> {
338 let ancestors = ancestors_inclusive(object_store, baseline)?;
339 Ok(ancestors
340 .values()
341 .flat_map(|block| block.patch_ids.iter().copied())
342 .collect())
343}
344
345fn candidate_sequence(
346 object_store: &impl ObjectReader,
347 baseline: ObjectId,
348 target: ObjectId,
349) -> Result<Vec<DecodedPatchOperation>> {
350 let excluded = baseline_reachable_patch_ids(object_store, baseline)?;
351 let mut operations = Vec::new();
352 for (_, block) in candidate_blocks(object_store, baseline, target)? {
353 for patch_id in block.patch_ids {
354 if excluded.contains(&patch_id) {
355 continue;
356 }
357 let envelope = object_store
358 .read_typed(patch_id, ObjectType::Patch)?
359 .ok_or_else(|| PrikkError::Integrity(format!("missing Patch {patch_id}")))?;
360 operations.extend(decode_patch_operations(
361 &envelope.canonical_payload,
362 envelope.schema_version,
363 )?);
364 }
365 }
366 Ok(operations)
367}
368
369pub(crate) fn candidate_patch_ids(
373 object_store: &impl ObjectReader,
374 baseline: ObjectId,
375 target: ObjectId,
376) -> Result<Vec<ObjectId>> {
377 let excluded = baseline_reachable_patch_ids(object_store, baseline)?;
378 Ok(candidate_blocks(object_store, baseline, target)?
379 .into_iter()
380 .flat_map(|(_, block)| block.patch_ids)
381 .filter(|patch_id| !excluded.contains(patch_id))
382 .collect())
383}
384
385pub(crate) fn verify_candidate_blocks_trusted(
399 object_store: &impl ObjectReader,
400 policy: &MaintainerTrustPolicy,
401 baseline: ObjectId,
402 target: ObjectId,
403) -> Result<()> {
404 for (block_id, _) in candidate_blocks(object_store, baseline, target)? {
405 let envelope = object_store
406 .read_typed(block_id, ObjectType::Block)?
407 .ok_or_else(|| PrikkError::Integrity(format!("missing Block {block_id}")))?;
408 verify_trusted_publication_envelope(policy, &envelope).map_err(|issue| {
409 PrikkError::InvalidSignature(format!(
410 "adopted Block {block_id} has no trusted MAINTAINER signature ({}: {})",
411 issue.code, issue.message
412 ))
413 })?;
414 }
415 Ok(())
416}
417
418fn read_block(object_store: &impl ObjectReader, block_id: ObjectId) -> Result<BlockPayload> {
419 let envelope = object_store
420 .read_typed(block_id, ObjectType::Block)?
421 .ok_or_else(|| PrikkError::Integrity(format!("missing Block {block_id}")))?;
422 BlockPayload::decode_canonical(&envelope.canonical_payload)
423}
424
425#[cfg(test)]
426mod tests;