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                    in_file: None,
470                },
471                [],
472            ),
473            StageTransition::Create {
474                sig_id: "fn::A".into(),
475                stage_id: "stage-0".into(),
476            },
477        );
478        log.put(&r0).unwrap();
479
480        let r1 = OperationRecord::new(
481            Operation::new(
482                OperationKind::ModifyBody {
483                    sig_id: "fn::A".into(),
484                    from_stage_id: "stage-0".into(),
485                    to_stage_id: "stage-1".into(),
486                    from_budget: None,
487                    to_budget: None,
488                },
489                [r0.op_id.clone()],
490            ),
491            StageTransition::Replace {
492                sig_id: "fn::A".into(),
493                from: "stage-0".into(),
494                to: "stage-1".into(),
495            },
496        );
497        log.put(&r1).unwrap();
498
499        let r2 = OperationRecord::new(
500            Operation::new(
501                OperationKind::ModifyBody {
502                    sig_id: "fn::A".into(),
503                    from_stage_id: "stage-0".into(),
504                    to_stage_id: "stage-2".into(),
505                    from_budget: None,
506                    to_budget: None,
507                },
508                [r0.op_id.clone()],
509            ),
510            StageTransition::Replace {
511                sig_id: "fn::A".into(),
512                from: "stage-0".into(),
513                to: "stage-2".into(),
514            },
515        );
516        log.put(&r2).unwrap();
517
518        (tmp, log, r1.op_id, r2.op_id)
519    }
520
521    #[test]
522    fn start_collects_conflicts() {
523        let (_tmp, log, dst, src) = fixture();
524        let session =
525            MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
526        assert_eq!(session.remaining_conflicts().len(), 1);
527        assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
528        assert_eq!(
529            session.remaining_conflicts()[0].kind,
530            ConflictKind::ModifyModify
531        );
532        assert_eq!(
533            session.remaining_conflicts()[0].ours.as_deref(),
534            Some("stage-1"),
535        );
536        assert_eq!(
537            session.remaining_conflicts()[0].theirs.as_deref(),
538            Some("stage-2"),
539        );
540        assert_eq!(
541            session.remaining_conflicts()[0].base.as_deref(),
542            Some("stage-0"),
543        );
544    }
545
546    #[test]
547    fn no_conflicts_when_branches_dont_overlap() {
548        let tmp = tempfile::tempdir().unwrap();
549        let log = OpLog::open(tmp.path()).unwrap();
550        let r0 = OperationRecord::new(
551            Operation::new(
552                OperationKind::AddFunction {
553                    sig_id: "fn::A".into(),
554                    stage_id: "stage-0".into(),
555                    effects: BTreeSet::new(),
556                    budget_cost: None,
557                    in_file: None,
558                },
559                [],
560            ),
561            StageTransition::Create {
562                sig_id: "fn::A".into(),
563                stage_id: "stage-0".into(),
564            },
565        );
566        log.put(&r0).unwrap();
567        let r1 = OperationRecord::new(
568            Operation::new(
569                OperationKind::AddFunction {
570                    sig_id: "fn::B".into(),
571                    stage_id: "stage-B".into(),
572                    effects: BTreeSet::new(),
573                    budget_cost: None,
574                    in_file: None,
575                },
576                [r0.op_id.clone()],
577            ),
578            StageTransition::Create {
579                sig_id: "fn::B".into(),
580                stage_id: "stage-B".into(),
581            },
582        );
583        log.put(&r1).unwrap();
584
585        let session =
586            MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
587        assert!(session.remaining_conflicts().is_empty());
588        assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
589    }
590
591    #[test]
592    fn resolve_take_ours_clears_conflict() {
593        let (_tmp, log, dst, src) = fixture();
594        let mut session =
595            MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
596        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
597        assert_eq!(verdicts.len(), 1);
598        assert!(verdicts[0].accepted);
599        assert!(session.remaining_conflicts().is_empty());
600    }
601
602    #[test]
603    fn resolve_take_theirs_clears_conflict() {
604        let (_tmp, log, dst, src) = fixture();
605        let mut session =
606            MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
607        let verdicts =
608            session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
609        assert!(verdicts[0].accepted);
610        assert!(session.remaining_conflicts().is_empty());
611    }
612
613    #[test]
614    fn resolve_unknown_conflict_is_rejected() {
615        let (_tmp, log, dst, src) = fixture();
616        let mut session =
617            MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
618        let verdicts =
619            session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
620        assert_eq!(verdicts.len(), 1);
621        assert!(!verdicts[0].accepted);
622        assert!(matches!(
623            verdicts[0].rejection,
624            Some(ResolutionRejection::UnknownConflict { .. }),
625        ));
626    }
627
628    #[test]
629    fn custom_op_without_two_parents_is_rejected() {
630        let (_tmp, log, dst, src) = fixture();
631        let mut session =
632            MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
633        // A custom op with empty parents — clearly not a merge.
634        let bad_op = Operation::new(
635            OperationKind::ModifyBody {
636                sig_id: "fn::A".into(),
637                from_stage_id: "stage-0".into(),
638                to_stage_id: "stage-X".into(),
639                from_budget: None,
640                to_budget: None,
641            },
642            [],
643        );
644        let verdicts = session.resolve(vec![(
645            "fn::A".into(),
646            Resolution::Custom { op: bad_op },
647        )]);
648        assert!(!verdicts[0].accepted);
649        assert!(matches!(
650            verdicts[0].rejection,
651            Some(ResolutionRejection::CustomOpMissingParents { .. }),
652        ));
653        // The conflict is still pending — bad resolutions don't
654        // clobber the slot.
655        assert_eq!(session.remaining_conflicts().len(), 1);
656    }
657
658    #[test]
659    fn custom_op_with_two_parents_is_accepted() {
660        let (_tmp, log, dst, src) = fixture();
661        let mut session =
662            MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
663        let merge_op = Operation::new(
664            OperationKind::ModifyBody {
665                sig_id: "fn::A".into(),
666                from_stage_id: "stage-0".into(),
667                to_stage_id: "stage-merged".into(),
668                from_budget: None,
669                to_budget: None,
670            },
671            [src.clone(), dst.clone()],
672        );
673        let verdicts = session.resolve(vec![(
674            "fn::A".into(),
675            Resolution::Custom { op: merge_op },
676        )]);
677        assert!(verdicts[0].accepted);
678        assert!(session.remaining_conflicts().is_empty());
679    }
680
681    #[test]
682    fn defer_keeps_conflict_pending() {
683        let (_tmp, log, dst, src) = fixture();
684        let mut session =
685            MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
686        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
687        // Defer is a valid resolution — accepted — but the conflict
688        // stays in `remaining_conflicts` since it still requires
689        // human attention.
690        assert!(verdicts[0].accepted);
691        assert_eq!(session.remaining_conflicts().len(), 1);
692    }
693
694    #[test]
695    fn commit_with_no_conflicts_succeeds() {
696        let tmp = tempfile::tempdir().unwrap();
697        let log = OpLog::open(tmp.path()).unwrap();
698        let session = MergeSession::start("ms-9", &log, None, None).unwrap();
699        let resolved = session.commit().unwrap();
700        assert!(resolved.is_empty());
701    }
702
703    #[test]
704    fn commit_with_unresolved_conflict_fails() {
705        let (_tmp, log, dst, src) = fixture();
706        let session =
707            MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
708        let err = session.commit().unwrap_err();
709        match err {
710            CommitError::ConflictsRemaining(ids) => {
711                assert_eq!(ids, vec!["fn::A".to_string()]);
712            }
713        }
714    }
715
716    #[test]
717    fn commit_with_defer_remaining_fails() {
718        let (_tmp, log, dst, src) = fixture();
719        let mut session =
720            MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
721        session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
722        let err = session.commit().unwrap_err();
723        match err {
724            CommitError::ConflictsRemaining(ids) => {
725                assert_eq!(ids, vec!["fn::A".to_string()]);
726            }
727        }
728    }
729
730    #[test]
731    fn commit_after_resolve_succeeds() {
732        let (_tmp, log, dst, src) = fixture();
733        let mut session =
734            MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
735        session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
736        let resolved = session.commit().unwrap();
737        assert_eq!(resolved.len(), 1);
738        assert_eq!(resolved[0].0, "fn::A");
739        assert!(matches!(resolved[0].1, Resolution::TakeOurs));
740    }
741
742    #[test]
743    fn batch_resolve_accepts_partial() {
744        // Mixed batch: one valid, one referencing an unknown
745        // conflict. The valid one should land; the bad one should
746        // be rejected without clobbering anything else.
747        let (_tmp, log, dst, src) = fixture();
748        let mut session =
749            MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
750        let verdicts = session.resolve(vec![
751            ("fn::A".into(), Resolution::TakeOurs),
752            ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
753        ]);
754        assert_eq!(verdicts.len(), 2);
755        assert!(verdicts[0].accepted);
756        assert!(!verdicts[1].accepted);
757        // fn::A is now resolved.
758        assert!(session.remaining_conflicts().is_empty());
759    }
760
761    #[test]
762    fn auto_resolved_outcomes_are_visible() {
763        let tmp = tempfile::tempdir().unwrap();
764        let log = OpLog::open(tmp.path()).unwrap();
765        // Single branch: just an add; no second branch to merge,
766        // but `MergeSession::start(... None ...)` still runs the
767        // engine. This documents what `auto_resolved` carries.
768        let r0 = OperationRecord::new(
769            Operation::new(
770                OperationKind::AddFunction {
771                    sig_id: "fn::A".into(),
772                    stage_id: "stage-0".into(),
773                    effects: BTreeSet::new(),
774                    budget_cost: None,
775                    in_file: None,
776                },
777                [],
778            ),
779            StageTransition::Create {
780                sig_id: "fn::A".into(),
781                stage_id: "stage-0".into(),
782            },
783        );
784        log.put(&r0).unwrap();
785        let session =
786            MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
787        assert!(session.remaining_conflicts().is_empty());
788        // src had a unique op vs the missing dst → it's an Src
789        // outcome surfaced as auto-resolved.
790        assert_eq!(session.auto_resolved.len(), 1);
791    }
792
793    // ---- #834: resolve_checked type-checks resolutions ----
794
795    /// A `ResolutionChecker` that rejects any projection setting the
796    /// conflicted sig to a named "poison" stage — a stand-in for the
797    /// real store-backed checker, which composes+type-checks. Records
798    /// the deltas it was asked about so tests can assert the
799    /// projection shape the session hands the checker.
800    struct MockChecker {
801        poison_stage: &'static str,
802        seen: std::cell::RefCell<Vec<BTreeMap<SigId, Option<StageId>>>>,
803    }
804    impl MockChecker {
805        fn new(poison_stage: &'static str) -> Self {
806            Self { poison_stage, seen: std::cell::RefCell::new(Vec::new()) }
807        }
808    }
809    impl ResolutionChecker for MockChecker {
810        fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String> {
811            self.seen.borrow_mut().push(delta.clone());
812            if delta.values().any(|s| s.as_deref() == Some(self.poison_stage)) {
813                vec![format!("stage {} does not type-check", self.poison_stage)]
814            } else {
815                Vec::new()
816            }
817        }
818    }
819
820    #[test]
821    fn resolve_checked_rejects_a_resolution_that_breaks_typechecking() {
822        // theirs == stage-2. A checker that poisons stage-2 must
823        // reject TakeTheirs and NOT record it — the session's
824        // accepted set stays type-correct.
825        let (_tmp, log, dst, src) = fixture();
826        let mut session = MergeSession::start("ms-c1", &log, Some(&src), Some(&dst)).unwrap();
827        let checker = MockChecker::new("stage-2");
828
829        let verdicts = session.resolve_checked(
830            vec![("fn::A".into(), Resolution::TakeTheirs)],
831            &checker,
832        );
833        assert_eq!(verdicts.len(), 1);
834        assert!(!verdicts[0].accepted);
835        assert!(matches!(
836            verdicts[0].rejection,
837            Some(ResolutionRejection::TypeError { .. })
838        ), "expected TypeError, got {:?}", verdicts[0].rejection);
839        // Not recorded → the conflict is still pending.
840        assert_eq!(session.remaining_conflicts().len(), 1);
841    }
842
843    #[test]
844    fn resolve_checked_accepts_a_resolution_that_composes() {
845        // TakeOurs keeps stage-1 (dst's side): the projection is
846        // empty (dst already has it), so the checker sees no poison
847        // and accepts.
848        let (_tmp, log, dst, src) = fixture();
849        let mut session = MergeSession::start("ms-c2", &log, Some(&src), Some(&dst)).unwrap();
850        let checker = MockChecker::new("stage-2");
851
852        let verdicts = session.resolve_checked(
853            vec![("fn::A".into(), Resolution::TakeOurs)],
854            &checker,
855        );
856        assert_eq!(verdicts.len(), 1);
857        assert!(verdicts[0].accepted, "got {:?}", verdicts[0].rejection);
858        assert!(session.remaining_conflicts().is_empty());
859        // TakeOurs contributes no delta against dst's head.
860        assert_eq!(checker.seen.borrow().last().unwrap().len(), 0);
861    }
862
863    #[test]
864    fn resolve_checked_still_rejects_structurally_invalid_before_typechecking() {
865        // An unknown conflict is rejected structurally; the checker
866        // is never consulted for it.
867        let (_tmp, log, dst, src) = fixture();
868        let mut session = MergeSession::start("ms-c3", &log, Some(&src), Some(&dst)).unwrap();
869        let checker = MockChecker::new("stage-2");
870        let verdicts = session.resolve_checked(
871            vec![("fn::NOPE".into(), Resolution::TakeTheirs)],
872            &checker,
873        );
874        assert!(!verdicts[0].accepted);
875        assert!(matches!(
876            verdicts[0].rejection,
877            Some(ResolutionRejection::UnknownConflict { .. })
878        ));
879        assert!(checker.seen.borrow().is_empty(), "checker must not run on a structural reject");
880    }
881
882    #[test]
883    fn projected_delta_sets_theirs_for_take_theirs() {
884        let (_tmp, log, dst, src) = fixture();
885        let session = MergeSession::start("ms-c4", &log, Some(&src), Some(&dst)).unwrap();
886        let mut res = BTreeMap::new();
887        res.insert("fn::A".to_string(), Resolution::TakeTheirs);
888        let delta = session.projected_delta(&res);
889        assert_eq!(delta.get("fn::A"), Some(&Some("stage-2".to_string())));
890    }
891}