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