Skip to main content

lex_vcs/
merge_session.rs

1//! Stateful merge sessions for programmatic conflict resolution (#134).
2//!
3//! Today's `lex_vcs::merge` returns a list of `MergeOutcome`s — auto-
4//! merged sigs *and* conflicts — and exits. To act on conflicts an
5//! agent has to:
6//!
7//! 1. Run `lex store-merge`.
8//! 2. Parse the JSON output.
9//! 3. Decide a resolution per conflict.
10//! 4. Manually edit source files.
11//! 5. Run `lex check`.
12//! 6. Run `lex publish`.
13//! 7. Loop on failure.
14//!
15//! Six round-trips for what should be one transaction. Worse, the
16//! agent edits *text* between steps 4 and 6 — the typed conflict
17//! the merge engine produced gets re-derived from the new text. The
18//! information loss is what the issue calls out.
19//!
20//! [`MergeSession`] gives the engine layer needed to expose merging
21//! as a state machine: `start` collects conflicts, `resolve` accepts
22//! batched [`Resolution`]s, `commit` finalizes when no conflicts
23//! remain. The HTTP wrapper (`POST /v1/merge/start` etc.) and the
24//! CLI mirror (`lex merge resolve`) compose on top of this.
25//!
26//! # Why a stateful session
27//!
28//! Merging conflicts iteratively is the natural agent loop:
29//! "submit 50 resolutions, see which were accepted, fix the ones
30//! that broke type-checking, retry." The session holds the
31//! in-progress state so the merge cost (LCA computation, op
32//! grouping, conflict classification) is paid once per merge,
33//! not once per resolution batch.
34//!
35//! # What's in the foundation slice
36//!
37//! The state machine: types, transitions, validation hook for
38//! resolved candidates, commit path that produces a fresh head op.
39//! Persistence (so a session survives a process restart) and the
40//! HTTP / CLI surfaces are subsequent slices.
41
42use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
47use crate::op_log::OpLog;
48use crate::operation::{OpId, Operation, SigId, StageId};
49
50/// Stable id for a merge in flight. Caller-supplied so the HTTP
51/// surface can map URLs to sessions without leaking session ids
52/// from the engine. Production callers will likely use UUIDs;
53/// tests use short strings.
54pub type MergeSessionId = String;
55
56/// Stable id for a conflict within a session. We use the SigId as
57/// the conflict id since conflicts are 1:1 with the sigs that have
58/// `MergeOutcome::Conflict`. If a future merge ever produces
59/// multiple conflicts on the same sig, this becomes a tuple.
60pub type ConflictId = SigId;
61
62/// Snapshot of one conflict the agent needs to resolve.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConflictRecord {
65    pub conflict_id: ConflictId,
66    pub sig_id: SigId,
67    pub kind: ConflictKind,
68    /// Stage on the LCA. `None` for `AddAdd` (no shared base) and
69    /// for sigs that didn't exist on the LCA.
70    pub base: Option<StageId>,
71    /// Stage on the dst (ours) side of the merge. `None` if dst
72    /// removed it.
73    pub ours: Option<StageId>,
74    /// Stage on the src (theirs) side of the merge. `None` if src
75    /// removed it.
76    pub theirs: Option<StageId>,
77}
78
79/// Choice for a single conflict.
80// `Operation` is the only payload-carrying variant and grew with
81// #280's typed transforms. Clippy flags the size disparity, but
82// boxing the field would churn callers (HTTP handler, CLI, tests)
83// for a heuristic warning — the heap allocation cost vs. the
84// occasional empty variant is not actually a hot path here.
85#[allow(clippy::large_enum_variant)]
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "kind", rename_all = "snake_case")]
88pub enum Resolution {
89    /// Keep dst's stage; discard src's.
90    TakeOurs,
91    /// Keep src's stage; discard dst's.
92    TakeTheirs,
93    /// Submit a brand-new op that supersedes both sides. The op's
94    /// parents must include both ours and theirs (the merge engine
95    /// validates this; see [`MergeSession::validate_resolution`]).
96    Custom { op: Operation },
97    /// Punt to a human reviewer. Surfaces as
98    /// [`CommitError::ConflictsRemaining`] on commit until removed.
99    Defer,
100}
101
102/// Why a resolution was rejected. Distinct from [`CommitError`]
103/// because a resolve call returns *per-conflict* verdicts; commit
104/// returns a single overall verdict.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum ResolutionRejection {
108    /// The conflict_id doesn't refer to any pending conflict in
109    /// the session. Either the agent invented one, or it was
110    /// already resolved and the session pruned it.
111    UnknownConflict { conflict_id: ConflictId },
112    /// The custom op's parents don't include both `ours` and
113    /// `theirs`. A custom resolution that doesn't acknowledge
114    /// both sides isn't a merge — it's a fork.
115    CustomOpMissingParents {
116        conflict_id: ConflictId,
117        expected: Vec<OpId>,
118        got: Vec<OpId>,
119    },
120    /// The resolution is structurally valid but the program it
121    /// produces — dst's head with this resolution (and every
122    /// resolution accepted so far) overlaid — does not type-check.
123    /// Only returned by [`MergeSession::resolve_checked`]; the
124    /// structural [`MergeSession::resolve`] never composes a program
125    /// and so never emits this. `errors` are the composed program's
126    /// type errors, rendered by the injected [`ResolutionChecker`].
127    TypeError {
128        conflict_id: ConflictId,
129        errors: Vec<String>,
130    },
131}
132
133/// Injected composer + type-checker for merge resolutions.
134///
135/// `lex-vcs` deliberately does not depend on `lex-store`, so a merge
136/// session cannot compose a program from stage ids on its own — it
137/// only knows the *shape* of the merge (which sig resolves to which
138/// stage). The caller, which holds the store, supplies a checker so
139/// [`MergeSession::resolve_checked`] can type-check a resolution the
140/// moment it is submitted rather than only at commit. This mirrors
141/// [`crate::IntentResolver`], the same dependency-injection seam the
142/// predicate engine uses.
143///
144/// Implementors receive the full projected post-merge **delta against
145/// dst's head** — `sig_id -> Some(stage)` to set that sig to `stage`,
146/// `sig_id -> None` to remove it. The implementor overlays the delta
147/// onto dst's current head, composes the stages, and type-checks:
148/// return the (possibly empty) list of type errors as strings. An
149/// empty vec means the resolution composes.
150pub trait ResolutionChecker {
151    fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String>;
152}
153
154/// Per-conflict outcome of a resolve call.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct ResolveVerdict {
157    pub conflict_id: ConflictId,
158    pub accepted: bool,
159    pub rejection: Option<ResolutionRejection>,
160}
161
162/// Why a commit failed. Conflicts-remaining is the most common
163/// case — agents are expected to iterate via resolve until this
164/// goes away.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum CommitError {
167    /// At least one conflict has no resolution or has
168    /// [`Resolution::Defer`]. The session is still alive; submit
169    /// resolutions and retry.
170    ConflictsRemaining(Vec<ConflictId>),
171}
172
173/// Stateful merge in flight. Hold one per active merge between
174/// `start` and `commit`. Sessions are not thread-safe; the HTTP
175/// wrapper is expected to wrap them in a `Mutex` keyed by
176/// [`MergeSessionId`].
177#[derive(Debug, Serialize, Deserialize)]
178pub struct MergeSession {
179    pub merge_id: MergeSessionId,
180    pub src_head: Option<OpId>,
181    pub dst_head: Option<OpId>,
182    pub lca: Option<OpId>,
183    /// Outcomes the engine resolved unilaterally — `Both` (both
184    /// sides agreed) and one-sided (`Src` / `Dst`). The agent sees
185    /// these for audit but doesn't need to act on them.
186    pub auto_resolved: Vec<MergeOutcome>,
187    /// Conflicts indexed by id. Removed as resolutions land.
188    conflicts: BTreeMap<ConflictId, ConflictRecord>,
189    /// Resolutions accumulated across resolve calls. Validated
190    /// against `conflicts` when applied.
191    resolutions: BTreeMap<ConflictId, Resolution>,
192}
193
194impl MergeSession {
195    /// Start a merge session. Runs the engine in [`crate::merge`]
196    /// and partitions the outcomes into auto-resolved and
197    /// conflicts-needing-attention.
198    pub fn start(
199        merge_id: impl Into<MergeSessionId>,
200        op_log: &OpLog,
201        src_head: Option<&OpId>,
202        dst_head: Option<&OpId>,
203    ) -> std::io::Result<Self> {
204        let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
205        let mut auto_resolved = Vec::new();
206        let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
207        for outcome in outcomes {
208            match outcome {
209                MergeOutcome::Conflict {
210                    sig_id,
211                    kind,
212                    base,
213                    src,
214                    dst,
215                } => {
216                    let conflict_id = sig_id.clone();
217                    conflicts.insert(
218                        conflict_id.clone(),
219                        ConflictRecord {
220                            conflict_id,
221                            sig_id,
222                            kind,
223                            base,
224                            // The merge engine returns `src` and
225                            // `dst` from src's and dst's perspective
226                            // respectively. We map dst→ours and
227                            // src→theirs, matching the canonical
228                            // git terminology and the issue text.
229                            ours: dst,
230                            theirs: src,
231                        },
232                    );
233                }
234                other => auto_resolved.push(other),
235            }
236        }
237        Ok(Self {
238            merge_id: merge_id.into(),
239            src_head: src_head.cloned(),
240            dst_head: dst_head.cloned(),
241            lca,
242            auto_resolved,
243            conflicts,
244            resolutions: BTreeMap::new(),
245        })
246    }
247
248    /// Pending conflicts (those without a non-defer resolution).
249    pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
250        self.conflicts
251            .values()
252            .filter(|c| {
253                !matches!(self.resolutions.get(&c.conflict_id),
254                    Some(Resolution::TakeOurs)
255                    | Some(Resolution::TakeTheirs)
256                    | Some(Resolution::Custom { .. }))
257            })
258            .collect()
259    }
260
261    /// Submit resolutions in batch. Returns one verdict per input.
262    /// Accepted resolutions are recorded; rejected ones leave the
263    /// previous resolution (if any) in place so partial submissions
264    /// don't clobber earlier good work.
265    pub fn resolve(
266        &mut self,
267        resolutions: Vec<(ConflictId, Resolution)>,
268    ) -> Vec<ResolveVerdict> {
269        let mut out = Vec::with_capacity(resolutions.len());
270        for (conflict_id, resolution) in resolutions {
271            match self.validate_resolution(&conflict_id, &resolution) {
272                Ok(()) => {
273                    self.resolutions.insert(conflict_id.clone(), resolution);
274                    out.push(ResolveVerdict {
275                        conflict_id,
276                        accepted: true,
277                        rejection: None,
278                    });
279                }
280                Err(rej) => {
281                    out.push(ResolveVerdict {
282                        conflict_id,
283                        accepted: false,
284                        rejection: Some(rej),
285                    });
286                }
287            }
288        }
289        out
290    }
291
292    /// Submit resolutions in batch, **type-checking each** against the
293    /// composed program before accepting it (#834).
294    ///
295    /// This is the loop the session was built for — "submit N
296    /// resolutions, see which broke type-checking, fix them, retry" —
297    /// made real. Structural validation ([`Self::validate_resolution`])
298    /// runs first; a structurally-valid resolution is then overlaid on
299    /// dst's head together with every resolution accepted so far, and
300    /// the injected [`ResolutionChecker`] type-checks the result. A
301    /// resolution whose composed program doesn't type-check is rejected
302    /// with [`ResolutionRejection::TypeError`] and *not* recorded, so
303    /// the session's accepted set stays type-correct at every step.
304    ///
305    /// Resolutions are processed in order and accumulate: a later
306    /// resolution is checked against the program the earlier accepted
307    /// ones already produced. Interdependent picks (two conflicts that
308    /// only compose together) should therefore be submitted in
309    /// dependency order, or a rejected one resubmitted after its
310    /// partner lands — the same way `git` needs both halves of an
311    /// intertwined conflict resolved before the tree builds. Unresolved
312    /// conflicts contribute nothing to the projection: they leave dst's
313    /// (always-valid) side standing, so a partial batch still composes.
314    pub fn resolve_checked(
315        &mut self,
316        resolutions: Vec<(ConflictId, Resolution)>,
317        checker: &dyn ResolutionChecker,
318    ) -> Vec<ResolveVerdict> {
319        let mut out = Vec::with_capacity(resolutions.len());
320        for (conflict_id, resolution) in resolutions {
321            // 1. Structural: known conflict, custom op acknowledges
322            //    both sides. Cheap, and a malformed op can't be
323            //    type-checked meaningfully anyway.
324            if let Err(rej) = self.validate_resolution(&conflict_id, &resolution) {
325                out.push(ResolveVerdict { conflict_id, accepted: false, rejection: Some(rej) });
326                continue;
327            }
328            // 2. Type: overlay this resolution on the ones accepted so
329            //    far and type-check the composed program.
330            let mut trial = self.resolutions.clone();
331            trial.insert(conflict_id.clone(), resolution.clone());
332            let delta = self.projected_delta(&trial);
333            let errors = checker.typecheck_projection(&delta);
334            if !errors.is_empty() {
335                out.push(ResolveVerdict {
336                    conflict_id: conflict_id.clone(),
337                    accepted: false,
338                    rejection: Some(ResolutionRejection::TypeError { conflict_id, errors }),
339                });
340                continue;
341            }
342            self.resolutions.insert(conflict_id.clone(), resolution);
343            out.push(ResolveVerdict { conflict_id, accepted: true, rejection: None });
344        }
345        out
346    }
347
348    /// The projected post-merge head-delta **against dst's head**,
349    /// assuming `resolutions`. This is exactly the `entries` a
350    /// `StageTransition::Merge` would record, and the input the
351    /// [`ResolutionChecker`] overlays on dst's head:
352    ///
353    /// * `MergeOutcome::Src` (a change only src made) → set it.
354    /// * `MergeOutcome::Both` / `Dst` → dst's head already reflects it;
355    ///   no delta.
356    /// * conflict resolved `TakeTheirs` → set src's stage.
357    /// * conflict resolved `Custom` → set the custom op's target
358    ///   ([`OperationKind::merge_target`]).
359    /// * conflict resolved `TakeOurs` → dst already has it; no delta.
360    /// * conflict unresolved / `Defer` → no delta (dst's side stands).
361    fn projected_delta(
362        &self,
363        resolutions: &BTreeMap<ConflictId, Resolution>,
364    ) -> BTreeMap<SigId, Option<StageId>> {
365        let mut delta: BTreeMap<SigId, Option<StageId>> = BTreeMap::new();
366        for outcome in &self.auto_resolved {
367            if let MergeOutcome::Src { sig_id, stage_id } = outcome {
368                delta.insert(sig_id.clone(), stage_id.clone());
369            }
370        }
371        for (conflict_id, record) in &self.conflicts {
372            match resolutions.get(conflict_id) {
373                Some(Resolution::TakeTheirs) => {
374                    delta.insert(record.sig_id.clone(), record.theirs.clone());
375                }
376                Some(Resolution::Custom { op }) => {
377                    if let Some((sig, stage)) = op.kind.merge_target() {
378                        delta.insert(sig, stage);
379                    }
380                }
381                // TakeOurs (dst already has it), Defer, or unresolved:
382                // no change against dst's head.
383                _ => {}
384            }
385        }
386        delta
387    }
388
389    /// Validate a single resolution against the session's pending
390    /// conflicts. Pure (no side effects); the caller decides
391    /// whether to accept.
392    pub fn validate_resolution(
393        &self,
394        conflict_id: &ConflictId,
395        resolution: &Resolution,
396    ) -> Result<(), ResolutionRejection> {
397        if !self.conflicts.contains_key(conflict_id) {
398            return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
399        }
400        if let Resolution::Custom { op } = resolution {
401            // Validate that the custom op's parent set acknowledges
402            // both sides. We don't have direct OpIds for the
403            // ours/theirs ops here (the conflict record carries
404            // stage ids), so the check is "the op has at least two
405            // parents" — a stronger check requires looking up the
406            // ops by sig and confirming they're in the parents,
407            // which is a follow-up enhancement.
408            //
409            // For the foundation slice this catches the obvious
410            // misuse (`Operation::new(kind, [])`) without
411            // reconstructing the merge engine's own validation.
412            if op.parents.len() < 2 {
413                return Err(ResolutionRejection::CustomOpMissingParents {
414                    conflict_id: conflict_id.clone(),
415                    expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
416                    got: op.parents.clone(),
417                });
418            }
419        }
420        Ok(())
421    }
422
423    /// Finalize the merge. On success returns the resolved
424    /// resolutions in conflict_id order. The caller is responsible
425    /// for synthesizing the final `Operation::Merge` op against the
426    /// store and persisting it; this function returns the engine's
427    /// view of "what to land," not the persisted op id.
428    pub fn commit(self) -> Result<Vec<(ConflictId, Resolution)>, CommitError> {
429        let unresolved: Vec<ConflictId> = self
430            .conflicts
431            .keys()
432            .filter(|id| {
433                !matches!(self.resolutions.get(*id),
434                    Some(Resolution::TakeOurs)
435                    | Some(Resolution::TakeTheirs)
436                    | Some(Resolution::Custom { .. }))
437            })
438            .cloned()
439            .collect();
440        if !unresolved.is_empty() {
441            return Err(CommitError::ConflictsRemaining(unresolved));
442        }
443        let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
444        resolved.sort_by(|a, b| a.0.cmp(&b.0));
445        Ok(resolved)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::operation::{OperationKind, OperationRecord, StageTransition};
453    use std::collections::BTreeSet;
454
455    /// Tiny fixture: one branch (dst) modifies fn::A from stage-0 to
456    /// stage-1; another (src) modifies fn::A to stage-2. The LCA is
457    /// the original add. The merge surfaces a `ModifyModify`
458    /// conflict on fn::A.
459    fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
460        let tmp = tempfile::tempdir().unwrap();
461        let log = OpLog::open(tmp.path()).unwrap();
462        let r0 = OperationRecord::new(
463            Operation::new(
464                OperationKind::AddFunction {
465                    sig_id: "fn::A".into(),
466                    stage_id: "stage-0".into(),
467                    effects: BTreeSet::new(),
468                    budget_cost: None,
469                },
470                [],
471            ),
472            StageTransition::Create {
473                sig_id: "fn::A".into(),
474                stage_id: "stage-0".into(),
475            },
476        );
477        log.put(&r0).unwrap();
478
479        let r1 = OperationRecord::new(
480            Operation::new(
481                OperationKind::ModifyBody {
482                    sig_id: "fn::A".into(),
483                    from_stage_id: "stage-0".into(),
484                    to_stage_id: "stage-1".into(),
485                    from_budget: None,
486                    to_budget: None,
487                },
488                [r0.op_id.clone()],
489            ),
490            StageTransition::Replace {
491                sig_id: "fn::A".into(),
492                from: "stage-0".into(),
493                to: "stage-1".into(),
494            },
495        );
496        log.put(&r1).unwrap();
497
498        let r2 = OperationRecord::new(
499            Operation::new(
500                OperationKind::ModifyBody {
501                    sig_id: "fn::A".into(),
502                    from_stage_id: "stage-0".into(),
503                    to_stage_id: "stage-2".into(),
504                    from_budget: None,
505                    to_budget: None,
506                },
507                [r0.op_id.clone()],
508            ),
509            StageTransition::Replace {
510                sig_id: "fn::A".into(),
511                from: "stage-0".into(),
512                to: "stage-2".into(),
513            },
514        );
515        log.put(&r2).unwrap();
516
517        (tmp, log, r1.op_id, r2.op_id)
518    }
519
520    #[test]
521    fn start_collects_conflicts() {
522        let (_tmp, log, dst, src) = fixture();
523        let session =
524            MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
525        assert_eq!(session.remaining_conflicts().len(), 1);
526        assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
527        assert_eq!(
528            session.remaining_conflicts()[0].kind,
529            ConflictKind::ModifyModify
530        );
531        assert_eq!(
532            session.remaining_conflicts()[0].ours.as_deref(),
533            Some("stage-1"),
534        );
535        assert_eq!(
536            session.remaining_conflicts()[0].theirs.as_deref(),
537            Some("stage-2"),
538        );
539        assert_eq!(
540            session.remaining_conflicts()[0].base.as_deref(),
541            Some("stage-0"),
542        );
543    }
544
545    #[test]
546    fn no_conflicts_when_branches_dont_overlap() {
547        let tmp = tempfile::tempdir().unwrap();
548        let log = OpLog::open(tmp.path()).unwrap();
549        let r0 = OperationRecord::new(
550            Operation::new(
551                OperationKind::AddFunction {
552                    sig_id: "fn::A".into(),
553                    stage_id: "stage-0".into(),
554                    effects: BTreeSet::new(),
555                    budget_cost: None,
556                },
557                [],
558            ),
559            StageTransition::Create {
560                sig_id: "fn::A".into(),
561                stage_id: "stage-0".into(),
562            },
563        );
564        log.put(&r0).unwrap();
565        let r1 = OperationRecord::new(
566            Operation::new(
567                OperationKind::AddFunction {
568                    sig_id: "fn::B".into(),
569                    stage_id: "stage-B".into(),
570                    effects: BTreeSet::new(),
571                    budget_cost: None,
572                },
573                [r0.op_id.clone()],
574            ),
575            StageTransition::Create {
576                sig_id: "fn::B".into(),
577                stage_id: "stage-B".into(),
578            },
579        );
580        log.put(&r1).unwrap();
581
582        let session =
583            MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
584        assert!(session.remaining_conflicts().is_empty());
585        assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
586    }
587
588    #[test]
589    fn resolve_take_ours_clears_conflict() {
590        let (_tmp, log, dst, src) = fixture();
591        let mut session =
592            MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
593        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
594        assert_eq!(verdicts.len(), 1);
595        assert!(verdicts[0].accepted);
596        assert!(session.remaining_conflicts().is_empty());
597    }
598
599    #[test]
600    fn resolve_take_theirs_clears_conflict() {
601        let (_tmp, log, dst, src) = fixture();
602        let mut session =
603            MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
604        let verdicts =
605            session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
606        assert!(verdicts[0].accepted);
607        assert!(session.remaining_conflicts().is_empty());
608    }
609
610    #[test]
611    fn resolve_unknown_conflict_is_rejected() {
612        let (_tmp, log, dst, src) = fixture();
613        let mut session =
614            MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
615        let verdicts =
616            session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
617        assert_eq!(verdicts.len(), 1);
618        assert!(!verdicts[0].accepted);
619        assert!(matches!(
620            verdicts[0].rejection,
621            Some(ResolutionRejection::UnknownConflict { .. }),
622        ));
623    }
624
625    #[test]
626    fn custom_op_without_two_parents_is_rejected() {
627        let (_tmp, log, dst, src) = fixture();
628        let mut session =
629            MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
630        // A custom op with empty parents — clearly not a merge.
631        let bad_op = Operation::new(
632            OperationKind::ModifyBody {
633                sig_id: "fn::A".into(),
634                from_stage_id: "stage-0".into(),
635                to_stage_id: "stage-X".into(),
636                from_budget: None,
637                to_budget: None,
638            },
639            [],
640        );
641        let verdicts = session.resolve(vec![(
642            "fn::A".into(),
643            Resolution::Custom { op: bad_op },
644        )]);
645        assert!(!verdicts[0].accepted);
646        assert!(matches!(
647            verdicts[0].rejection,
648            Some(ResolutionRejection::CustomOpMissingParents { .. }),
649        ));
650        // The conflict is still pending — bad resolutions don't
651        // clobber the slot.
652        assert_eq!(session.remaining_conflicts().len(), 1);
653    }
654
655    #[test]
656    fn custom_op_with_two_parents_is_accepted() {
657        let (_tmp, log, dst, src) = fixture();
658        let mut session =
659            MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
660        let merge_op = Operation::new(
661            OperationKind::ModifyBody {
662                sig_id: "fn::A".into(),
663                from_stage_id: "stage-0".into(),
664                to_stage_id: "stage-merged".into(),
665                from_budget: None,
666                to_budget: None,
667            },
668            [src.clone(), dst.clone()],
669        );
670        let verdicts = session.resolve(vec![(
671            "fn::A".into(),
672            Resolution::Custom { op: merge_op },
673        )]);
674        assert!(verdicts[0].accepted);
675        assert!(session.remaining_conflicts().is_empty());
676    }
677
678    #[test]
679    fn defer_keeps_conflict_pending() {
680        let (_tmp, log, dst, src) = fixture();
681        let mut session =
682            MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
683        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
684        // Defer is a valid resolution — accepted — but the conflict
685        // stays in `remaining_conflicts` since it still requires
686        // human attention.
687        assert!(verdicts[0].accepted);
688        assert_eq!(session.remaining_conflicts().len(), 1);
689    }
690
691    #[test]
692    fn commit_with_no_conflicts_succeeds() {
693        let tmp = tempfile::tempdir().unwrap();
694        let log = OpLog::open(tmp.path()).unwrap();
695        let session = MergeSession::start("ms-9", &log, None, None).unwrap();
696        let resolved = session.commit().unwrap();
697        assert!(resolved.is_empty());
698    }
699
700    #[test]
701    fn commit_with_unresolved_conflict_fails() {
702        let (_tmp, log, dst, src) = fixture();
703        let session =
704            MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
705        let err = session.commit().unwrap_err();
706        match err {
707            CommitError::ConflictsRemaining(ids) => {
708                assert_eq!(ids, vec!["fn::A".to_string()]);
709            }
710        }
711    }
712
713    #[test]
714    fn commit_with_defer_remaining_fails() {
715        let (_tmp, log, dst, src) = fixture();
716        let mut session =
717            MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
718        session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
719        let err = session.commit().unwrap_err();
720        match err {
721            CommitError::ConflictsRemaining(ids) => {
722                assert_eq!(ids, vec!["fn::A".to_string()]);
723            }
724        }
725    }
726
727    #[test]
728    fn commit_after_resolve_succeeds() {
729        let (_tmp, log, dst, src) = fixture();
730        let mut session =
731            MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
732        session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
733        let resolved = session.commit().unwrap();
734        assert_eq!(resolved.len(), 1);
735        assert_eq!(resolved[0].0, "fn::A");
736        assert!(matches!(resolved[0].1, Resolution::TakeOurs));
737    }
738
739    #[test]
740    fn batch_resolve_accepts_partial() {
741        // Mixed batch: one valid, one referencing an unknown
742        // conflict. The valid one should land; the bad one should
743        // be rejected without clobbering anything else.
744        let (_tmp, log, dst, src) = fixture();
745        let mut session =
746            MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
747        let verdicts = session.resolve(vec![
748            ("fn::A".into(), Resolution::TakeOurs),
749            ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
750        ]);
751        assert_eq!(verdicts.len(), 2);
752        assert!(verdicts[0].accepted);
753        assert!(!verdicts[1].accepted);
754        // fn::A is now resolved.
755        assert!(session.remaining_conflicts().is_empty());
756    }
757
758    #[test]
759    fn auto_resolved_outcomes_are_visible() {
760        let tmp = tempfile::tempdir().unwrap();
761        let log = OpLog::open(tmp.path()).unwrap();
762        // Single branch: just an add; no second branch to merge,
763        // but `MergeSession::start(... None ...)` still runs the
764        // engine. This documents what `auto_resolved` carries.
765        let r0 = OperationRecord::new(
766            Operation::new(
767                OperationKind::AddFunction {
768                    sig_id: "fn::A".into(),
769                    stage_id: "stage-0".into(),
770                    effects: BTreeSet::new(),
771                    budget_cost: None,
772                },
773                [],
774            ),
775            StageTransition::Create {
776                sig_id: "fn::A".into(),
777                stage_id: "stage-0".into(),
778            },
779        );
780        log.put(&r0).unwrap();
781        let session =
782            MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
783        assert!(session.remaining_conflicts().is_empty());
784        // src had a unique op vs the missing dst → it's an Src
785        // outcome surfaced as auto-resolved.
786        assert_eq!(session.auto_resolved.len(), 1);
787    }
788
789    // ---- #834: resolve_checked type-checks resolutions ----
790
791    /// A `ResolutionChecker` that rejects any projection setting the
792    /// conflicted sig to a named "poison" stage — a stand-in for the
793    /// real store-backed checker, which composes+type-checks. Records
794    /// the deltas it was asked about so tests can assert the
795    /// projection shape the session hands the checker.
796    struct MockChecker {
797        poison_stage: &'static str,
798        seen: std::cell::RefCell<Vec<BTreeMap<SigId, Option<StageId>>>>,
799    }
800    impl MockChecker {
801        fn new(poison_stage: &'static str) -> Self {
802            Self { poison_stage, seen: std::cell::RefCell::new(Vec::new()) }
803        }
804    }
805    impl ResolutionChecker for MockChecker {
806        fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String> {
807            self.seen.borrow_mut().push(delta.clone());
808            if delta.values().any(|s| s.as_deref() == Some(self.poison_stage)) {
809                vec![format!("stage {} does not type-check", self.poison_stage)]
810            } else {
811                Vec::new()
812            }
813        }
814    }
815
816    #[test]
817    fn resolve_checked_rejects_a_resolution_that_breaks_typechecking() {
818        // theirs == stage-2. A checker that poisons stage-2 must
819        // reject TakeTheirs and NOT record it — the session's
820        // accepted set stays type-correct.
821        let (_tmp, log, dst, src) = fixture();
822        let mut session = MergeSession::start("ms-c1", &log, Some(&src), Some(&dst)).unwrap();
823        let checker = MockChecker::new("stage-2");
824
825        let verdicts = session.resolve_checked(
826            vec![("fn::A".into(), Resolution::TakeTheirs)],
827            &checker,
828        );
829        assert_eq!(verdicts.len(), 1);
830        assert!(!verdicts[0].accepted);
831        assert!(matches!(
832            verdicts[0].rejection,
833            Some(ResolutionRejection::TypeError { .. })
834        ), "expected TypeError, got {:?}", verdicts[0].rejection);
835        // Not recorded → the conflict is still pending.
836        assert_eq!(session.remaining_conflicts().len(), 1);
837    }
838
839    #[test]
840    fn resolve_checked_accepts_a_resolution_that_composes() {
841        // TakeOurs keeps stage-1 (dst's side): the projection is
842        // empty (dst already has it), so the checker sees no poison
843        // and accepts.
844        let (_tmp, log, dst, src) = fixture();
845        let mut session = MergeSession::start("ms-c2", &log, Some(&src), Some(&dst)).unwrap();
846        let checker = MockChecker::new("stage-2");
847
848        let verdicts = session.resolve_checked(
849            vec![("fn::A".into(), Resolution::TakeOurs)],
850            &checker,
851        );
852        assert_eq!(verdicts.len(), 1);
853        assert!(verdicts[0].accepted, "got {:?}", verdicts[0].rejection);
854        assert!(session.remaining_conflicts().is_empty());
855        // TakeOurs contributes no delta against dst's head.
856        assert_eq!(checker.seen.borrow().last().unwrap().len(), 0);
857    }
858
859    #[test]
860    fn resolve_checked_still_rejects_structurally_invalid_before_typechecking() {
861        // An unknown conflict is rejected structurally; the checker
862        // is never consulted for it.
863        let (_tmp, log, dst, src) = fixture();
864        let mut session = MergeSession::start("ms-c3", &log, Some(&src), Some(&dst)).unwrap();
865        let checker = MockChecker::new("stage-2");
866        let verdicts = session.resolve_checked(
867            vec![("fn::NOPE".into(), Resolution::TakeTheirs)],
868            &checker,
869        );
870        assert!(!verdicts[0].accepted);
871        assert!(matches!(
872            verdicts[0].rejection,
873            Some(ResolutionRejection::UnknownConflict { .. })
874        ));
875        assert!(checker.seen.borrow().is_empty(), "checker must not run on a structural reject");
876    }
877
878    #[test]
879    fn projected_delta_sets_theirs_for_take_theirs() {
880        let (_tmp, log, dst, src) = fixture();
881        let session = MergeSession::start("ms-c4", &log, Some(&src), Some(&dst)).unwrap();
882        let mut res = BTreeMap::new();
883        res.insert("fn::A".to_string(), Resolution::TakeTheirs);
884        let delta = session.projected_delta(&res);
885        assert_eq!(delta.get("fn::A"), Some(&Some("stage-2".to_string())));
886    }
887}