Skip to main content

maw/model/
conflict.rs

1//! Structured conflict model — variant types, localization, and serialization (§5.7, §6.4).
2//!
3//! Conflicts in Manifold are structured and localizable — per file, per region,
4//! per edit atom — not giant marker soup. Each variant captures the minimal data
5//! needed to present the conflict to an agent or human for resolution.
6//!
7//! # Conflict Variants
8//!
9//! | Variant | Description |
10//! |---------|-------------|
11//! | [`Conflict::Content`] | Two or more workspaces modified the same file region |
12//! | [`Conflict::AddAdd`] | Same path added independently with different content |
13//! | [`Conflict::ModifyDelete`] | One workspace modified, another deleted the file |
14//! | [`Conflict::DivergentRename`] | Same file renamed to different destinations |
15//!
16//! # Localization Types
17//!
18//! | Type | Description |
19//! |------|-------------|
20//! | [`Region`] | Where in a file: line ranges, AST nodes, or whole file |
21//! | [`ConflictReason`] | Why the conflict occurred: overlapping edits, same AST node, etc. |
22//! | [`AtomEdit`] | One workspace's contribution to a conflict region |
23//! | [`ConflictAtom`] | A localized conflict with base region, edits, and reason |
24//!
25//! # Serialization
26//!
27//! All types use tagged JSON for clean, agent-parseable output:
28//!
29//! ```json
30//! {
31//!   "type": "content",
32//!   "path": "src/lib.rs",
33//!   "file_id": "00000000000000000000000000000001",
34//!   "base": "aaaa...",
35//!   "sides": [...],
36//!   "atoms": [{
37//!     "base_region": { "kind": "lines", "start": 42, "end": 67 },
38//!     "edits": [
39//!       { "workspace": "alice", "region": { "kind": "lines", "start": 42, "end": 55 }, "content": "..." },
40//!       { "workspace": "bob", "region": { "kind": "lines", "start": 50, "end": 67 }, "content": "..." }
41//!     ],
42//!     "reason": { "reason": "overlapping_line_edits", "description": "Both sides edited lines 42-67" }
43//!   }]
44//! }
45//! ```
46
47use std::fmt;
48use std::path::PathBuf;
49
50use serde::{Deserialize, Serialize};
51
52use super::ordering::OrderingKey;
53use super::patch::FileId;
54use super::types::GitOid;
55
56// ---------------------------------------------------------------------------
57// ConflictSide
58// ---------------------------------------------------------------------------
59
60/// One side of a conflict — identifies which workspace contributed what content.
61///
62/// # Example
63///
64/// In a two-way content conflict between workspaces `alice` and `bob`:
65/// - Side 0: `{ workspace: "alice", content: <oid-of-alice-version>, timestamp: ... }`
66/// - Side 1: `{ workspace: "bob", content: <oid-of-bob-version>, timestamp: ... }`
67///
68/// Sides are sorted by `(workspace_id, timestamp)` for deterministic output.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ConflictSide {
71    /// The workspace that produced this side of the conflict.
72    pub workspace: String,
73
74    /// Git blob OID of the file content from this workspace.
75    pub content: GitOid,
76
77    /// The ordering key of the operation that produced this side.
78    ///
79    /// Used for display and tie-breaking, not for conflict resolution logic.
80    pub timestamp: OrderingKey,
81}
82
83impl ConflictSide {
84    /// Create a new conflict side.
85    #[must_use]
86    pub const fn new(workspace: String, content: GitOid, timestamp: OrderingKey) -> Self {
87        Self {
88            workspace,
89            content,
90            timestamp,
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Region — localization of a conflict within a file
97// ---------------------------------------------------------------------------
98
99/// A region within a file that participates in a conflict.
100///
101/// Regions localize conflicts to specific parts of a file — either line ranges
102/// or AST node spans. This is the difference between "file has conflict" and
103/// "lines 42-67 of function `process_order` have a conflict."
104///
105/// # Serialization
106///
107/// Uses `#[serde(tag = "kind")]` with `snake_case` variant names:
108///
109/// ```json
110/// { "kind": "lines", "start": 42, "end": 67 }
111/// { "kind": "ast_node", "node_kind": "function", "name": "process_order", "start_byte": 1024, "end_byte": 2048 }
112/// ```
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(tag = "kind", rename_all = "snake_case")]
115pub enum Region {
116    /// A contiguous range of lines (1-indexed, inclusive start, exclusive end).
117    ///
118    /// # Example
119    ///
120    /// `Region::Lines { start: 10, end: 15 }` means lines 10..15.
121    Lines {
122        /// First line of the region (1-indexed, inclusive).
123        start: u32,
124        /// One past the last line of the region (exclusive).
125        end: u32,
126    },
127
128    /// An AST node identified by tree-sitter node kind and optional name.
129    ///
130    /// Used when the merge engine has parsed the file and can identify
131    /// conflicts at the syntax-tree level rather than raw line ranges.
132    ///
133    /// # Example
134    ///
135    /// ```text
136    /// AstNode { node_kind: "function_item", name: Some("process_order"), start_byte: 1024, end_byte: 2048 }
137    /// ```
138    AstNode {
139        /// The tree-sitter node kind (e.g., "`function_item`", "`struct_item`").
140        node_kind: String,
141        /// The name of the node if available (e.g., function name, struct name).
142        name: Option<String>,
143        /// Start byte offset in the file (0-indexed).
144        start_byte: u32,
145        /// End byte offset in the file (exclusive).
146        end_byte: u32,
147    },
148
149    /// The entire file (used when region-level granularity is not available).
150    WholeFile,
151}
152
153impl Region {
154    /// Create a line-based region.
155    #[must_use]
156    pub const fn lines(start: u32, end: u32) -> Self {
157        Self::Lines { start, end }
158    }
159
160    /// Create an AST-node region.
161    #[must_use]
162    pub fn ast_node(
163        node_kind: impl Into<String>,
164        name: Option<String>,
165        start_byte: u32,
166        end_byte: u32,
167    ) -> Self {
168        Self::AstNode {
169            node_kind: node_kind.into(),
170            name,
171            start_byte,
172            end_byte,
173        }
174    }
175
176    /// Create a whole-file region.
177    #[must_use]
178    pub const fn whole_file() -> Self {
179        Self::WholeFile
180    }
181
182    /// Return a human-readable summary of this region.
183    #[must_use]
184    pub fn summary(&self) -> String {
185        match self {
186            Self::Lines { start, end } => format!("lines {start}..{end}"),
187            Self::AstNode {
188                node_kind, name, ..
189            } => name
190                .as_ref()
191                .map_or_else(|| node_kind.clone(), |n| format!("{node_kind} `{n}`")),
192            Self::WholeFile => "whole file".to_string(),
193        }
194    }
195}
196
197impl fmt::Display for Region {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        write!(f, "{}", self.summary())
200    }
201}
202
203// ---------------------------------------------------------------------------
204// ConflictReason — why a conflict occurred
205// ---------------------------------------------------------------------------
206
207/// Explains why a specific conflict region could not be auto-merged.
208///
209/// Agents use this to decide resolution strategy. For example,
210/// `OverlappingLineEdits` suggests a line-level diff3 resolution might work,
211/// while `SameAstNodeModified` suggests looking at the AST structure.
212///
213/// # Serialization
214///
215/// Uses `#[serde(tag = "reason")]` with `snake_case` variant names:
216///
217/// ```json
218/// { "reason": "overlapping_line_edits", "description": "Both sides edited lines 42-67" }
219/// ```
220#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(tag = "reason", rename_all = "snake_case")]
222pub enum ConflictReason {
223    /// Two or more workspaces edited overlapping line ranges.
224    ///
225    /// This is the most common conflict reason. The line ranges from each
226    /// side overlap, making a clean merge impossible without human/agent input.
227    OverlappingLineEdits {
228        /// Human-readable description of the overlap.
229        description: String,
230    },
231
232    /// Two or more workspaces modified the same AST node.
233    ///
234    /// Even if the exact line ranges don't overlap, modifying the same
235    /// function or struct from multiple workspaces requires review.
236    SameAstNodeModified {
237        /// Human-readable description of which AST node is affected.
238        description: String,
239    },
240
241    /// A symbol lifecycle mismatch (add/delete/rename intent divergence).
242    SymbolLifecycleDivergence {
243        /// Human-readable description.
244        description: String,
245    },
246
247    /// Function/method signature drift across variants.
248    SignatureDrift {
249        /// Human-readable description.
250        description: String,
251    },
252
253    /// Incompatible API-level edits (same symbol, incompatible behavior/contract edits).
254    IncompatibleApiEdits {
255        /// Human-readable description.
256        description: String,
257    },
258
259    /// The edits are non-commutative — applying them in different orders
260    /// produces different results.
261    ///
262    /// This is the formal CRDT-theory reason for a conflict. It subsumes
263    /// the other reasons but is used when no more specific reason applies.
264    NonCommutativeEdits {
265        /// Human-readable description.
266        description: String,
267    },
268
269    /// A custom reason not covered by the predefined variants.
270    ///
271    /// Used by custom merge drivers or specialized analysis tools.
272    Custom {
273        /// The custom reason string.
274        description: String,
275    },
276}
277
278impl ConflictReason {
279    /// Create an overlapping line edits reason.
280    #[must_use]
281    pub fn overlapping(description: impl Into<String>) -> Self {
282        Self::OverlappingLineEdits {
283            description: description.into(),
284        }
285    }
286
287    /// Create a same-AST-node-modified reason.
288    #[must_use]
289    pub fn same_ast_node(description: impl Into<String>) -> Self {
290        Self::SameAstNodeModified {
291            description: description.into(),
292        }
293    }
294
295    /// Create a symbol lifecycle divergence reason.
296    #[must_use]
297    pub fn symbol_lifecycle(description: impl Into<String>) -> Self {
298        Self::SymbolLifecycleDivergence {
299            description: description.into(),
300        }
301    }
302
303    /// Create a signature drift reason.
304    #[must_use]
305    pub fn signature_drift(description: impl Into<String>) -> Self {
306        Self::SignatureDrift {
307            description: description.into(),
308        }
309    }
310
311    /// Create an incompatible API edits reason.
312    #[must_use]
313    pub fn incompatible_api_edits(description: impl Into<String>) -> Self {
314        Self::IncompatibleApiEdits {
315            description: description.into(),
316        }
317    }
318
319    /// Create a non-commutative edits reason.
320    #[must_use]
321    pub fn non_commutative(description: impl Into<String>) -> Self {
322        Self::NonCommutativeEdits {
323            description: description.into(),
324        }
325    }
326
327    /// Create a custom reason.
328    #[must_use]
329    pub fn custom(description: impl Into<String>) -> Self {
330        Self::Custom {
331            description: description.into(),
332        }
333    }
334
335    /// Return the human-readable description.
336    #[must_use]
337    pub fn description(&self) -> &str {
338        match self {
339            Self::OverlappingLineEdits { description }
340            | Self::SameAstNodeModified { description }
341            | Self::SymbolLifecycleDivergence { description }
342            | Self::SignatureDrift { description }
343            | Self::IncompatibleApiEdits { description }
344            | Self::NonCommutativeEdits { description }
345            | Self::Custom { description } => description,
346        }
347    }
348
349    /// Return the reason variant name as a static string.
350    #[must_use]
351    pub const fn variant_name(&self) -> &'static str {
352        match self {
353            Self::OverlappingLineEdits { .. } => "overlapping_line_edits",
354            Self::SameAstNodeModified { .. } => "same_ast_node_modified",
355            Self::SymbolLifecycleDivergence { .. } => "symbol_lifecycle_divergence",
356            Self::SignatureDrift { .. } => "signature_drift",
357            Self::IncompatibleApiEdits { .. } => "incompatible_api_edits",
358            Self::NonCommutativeEdits { .. } => "non_commutative_edits",
359            Self::Custom { .. } => "custom",
360        }
361    }
362}
363
364impl fmt::Display for ConflictReason {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        write!(f, "{}", self.description())
367    }
368}
369
370// ---------------------------------------------------------------------------
371// AtomEdit — one workspace's contribution to a conflict atom
372// ---------------------------------------------------------------------------
373
374/// A single workspace's edit within a conflict atom.
375///
376/// Each `AtomEdit` represents what one workspace did to a conflicted region.
377/// The collection of `AtomEdit`s within a `ConflictAtom` shows all the
378/// divergent changes that produced the conflict.
379///
380/// # Example
381///
382/// Workspace `alice` replaced lines 10-15 with new code. Workspace `bob`
383/// replaced lines 12-18 with different code. Both edits would appear as
384/// `AtomEdit` entries within the same `ConflictAtom`.
385#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
386pub struct AtomEdit {
387    /// The workspace that made this edit.
388    pub workspace: String,
389
390    /// The region in the workspace's version of the file where the edit lands.
391    pub region: Region,
392
393    /// The text content of the edit (the new content from this workspace).
394    ///
395    /// May be empty for deletions.
396    pub content: String,
397}
398
399impl AtomEdit {
400    /// Create a new atom edit.
401    #[must_use]
402    pub fn new(workspace: impl Into<String>, region: Region, content: impl Into<String>) -> Self {
403        Self {
404            workspace: workspace.into(),
405            region,
406            content: content.into(),
407        }
408    }
409}
410
411impl fmt::Display for AtomEdit {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        let content_preview = if self.content.len() > 40 {
414            format!("{}...", &self.content[..40])
415        } else {
416            self.content.clone()
417        };
418        write!(
419            f,
420            "{} @ {}: {:?}",
421            self.workspace, self.region, content_preview
422        )
423    }
424}
425
426/// Machine-readable semantic diagnostics attached to a conflict atom.
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct SemanticConflictExplanation {
429    /// Stable semantic rule identifier.
430    pub rule: String,
431    /// Confidence score in the range 0-100.
432    pub confidence: u8,
433    /// Compact rationale for agents.
434    pub rationale: String,
435    /// Optional evidence fragments used by the classifier.
436    #[serde(default)]
437    pub evidence: Vec<String>,
438}
439
440impl SemanticConflictExplanation {
441    #[must_use]
442    pub fn new(
443        rule: impl Into<String>,
444        confidence: u8,
445        rationale: impl Into<String>,
446        evidence: Vec<String>,
447    ) -> Self {
448        Self {
449            rule: rule.into(),
450            confidence,
451            rationale: rationale.into(),
452            evidence,
453        }
454    }
455}
456
457// ---------------------------------------------------------------------------
458// ConflictAtom — localized conflict region with edits and reason
459// ---------------------------------------------------------------------------
460
461/// A localized conflict region within a file.
462///
463/// A `ConflictAtom` pinpoints exactly WHERE a conflict occurs and WHY it
464/// cannot be auto-merged. It carries the base region (in the common ancestor),
465/// each workspace's edit to that region, and the reason for the conflict.
466///
467/// # Design philosophy
468///
469/// From §5.7: "An agent receiving 'two edits are non-commutative because both
470/// modify AST node `process_order` at lines 42-67' can resolve the conflict
471/// surgically. An agent receiving 'file has conflict' with marker soup cannot."
472///
473/// # Example JSON
474///
475/// ```json
476/// {
477///   "base_region": { "kind": "lines", "start": 42, "end": 67 },
478///   "edits": [
479///     { "workspace": "alice", "region": { "kind": "lines", "start": 42, "end": 55 }, "content": "fn process_order(..." },
480///     { "workspace": "bob", "region": { "kind": "lines", "start": 50, "end": 67 }, "content": "fn process_order(..." }
481///   ],
482///   "reason": { "reason": "overlapping_line_edits", "description": "Both sides edited lines 42-67" }
483/// }
484/// ```
485#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
486pub struct ConflictAtom {
487    /// The region in the base (common ancestor) version where the conflict occurs.
488    pub base_region: Region,
489
490    /// Each workspace's edit to the conflicted region.
491    ///
492    /// Always has ≥ 2 entries (otherwise it wouldn't be a conflict).
493    /// Sorted by workspace name for deterministic output.
494    pub edits: Vec<AtomEdit>,
495
496    /// Why this region could not be auto-merged.
497    pub reason: ConflictReason,
498
499    /// Optional semantic explanation used by downstream automation.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub semantic: Option<SemanticConflictExplanation>,
502}
503
504impl ConflictAtom {
505    /// Create a new conflict atom.
506    #[must_use]
507    pub const fn new(base_region: Region, edits: Vec<AtomEdit>, reason: ConflictReason) -> Self {
508        Self {
509            base_region,
510            edits,
511            reason,
512            semantic: None,
513        }
514    }
515
516    /// Attach semantic explanation metadata.
517    #[must_use]
518    pub fn with_semantic(mut self, semantic: SemanticConflictExplanation) -> Self {
519        self.semantic = Some(semantic);
520        self
521    }
522
523    /// Create a simple line-overlap conflict atom (convenience constructor).
524    #[must_use]
525    pub fn line_overlap(
526        start: u32,
527        end: u32,
528        edits: Vec<AtomEdit>,
529        description: impl Into<String>,
530    ) -> Self {
531        Self {
532            base_region: Region::lines(start, end),
533            edits,
534            reason: ConflictReason::overlapping(description),
535            semantic: None,
536        }
537    }
538
539    /// Return a human-readable summary of this atom.
540    #[must_use]
541    pub fn summary(&self) -> String {
542        let ws: Vec<_> = self.edits.iter().map(|e| e.workspace.as_str()).collect();
543        format!(
544            "{} — {} [{}]",
545            self.base_region.summary(),
546            self.reason,
547            ws.join(", ")
548        )
549    }
550}
551
552impl fmt::Display for ConflictAtom {
553    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554        write!(f, "{}", self.summary())
555    }
556}
557
558// ---------------------------------------------------------------------------
559// Conflict
560// ---------------------------------------------------------------------------
561
562/// A structured conflict produced by the merge engine.
563///
564/// Each variant captures a specific kind of merge conflict with enough data
565/// to present it to an agent for resolution. Conflicts never appear in git
566/// commits on main — they are resolved before epoch advancement.
567///
568/// # Serialization
569///
570/// Uses `#[serde(tag = "type")]` for tagged JSON output with `snake_case` names.
571///
572/// ```
573/// use maw::model::conflict::{Conflict, ConflictSide, ConflictAtom};
574/// use maw::model::types::GitOid;
575/// use maw::model::ordering::OrderingKey;
576/// use maw::model::patch::FileId;
577/// use maw::model::types::EpochId;
578///
579/// let oid = GitOid::new(&"a".repeat(40)).unwrap();
580/// let epoch = EpochId::new(&"e".repeat(40)).unwrap();
581/// let ts = OrderingKey::new(epoch.clone(), "ws-1".parse().unwrap(), 1, 1000);
582/// let side = ConflictSide::new("ws-1".into(), oid.clone(), ts);
583///
584/// let conflict = Conflict::AddAdd {
585///     path: "src/new.rs".into(),
586///     sides: vec![side],
587/// };
588///
589/// let json = serde_json::to_string(&conflict).unwrap();
590/// assert!(json.contains("\"type\":\"add_add\""));
591/// ```
592#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
593#[serde(tag = "type", rename_all = "snake_case")]
594pub enum Conflict {
595    /// Two or more workspaces modified the same region of the same file.
596    ///
597    /// This is the most common conflict type. The `base` field holds the
598    /// common ancestor blob OID, and each `side` holds a workspace's version.
599    /// The `atoms` list localizes the conflict to specific regions.
600    ///
601    /// # Example
602    ///
603    /// Workspace `alice` edits lines 10-15 of `src/lib.rs`, workspace `bob`
604    /// edits lines 12-18. The base is the original blob, sides contain each
605    /// workspace's full-file blob, and atoms pinpoint lines 12-15 as the
606    /// overlapping conflict region.
607    Content {
608        /// Path to the conflicted file (relative to repo root).
609        path: PathBuf,
610
611        /// Stable file identity (survives renames).
612        file_id: FileId,
613
614        /// Git blob OID of the common ancestor (base) version.
615        ///
616        /// `None` if no common ancestor exists (e.g., both sides added
617        /// different content to a previously nonexistent path with the
618        /// same `FileId` — unusual but possible after merge).
619        base: Option<GitOid>,
620
621        /// The conflicting sides (one per workspace that modified this region).
622        ///
623        /// Always has ≥ 2 entries. Sorted by workspace ID for determinism.
624        sides: Vec<ConflictSide>,
625
626        /// Localized conflict regions within the file.
627        ///
628        /// May be empty if region-level granularity is not yet computed
629        /// (e.g., binary files or pre-atom analysis).
630        atoms: Vec<ConflictAtom>,
631    },
632
633    /// Two or more workspaces independently added a file at the same path
634    /// with different content.
635    ///
636    /// Unlike `Content` conflicts, there is no common base — the file did
637    /// not exist before. Each side contains the independently added content.
638    ///
639    /// # Example
640    ///
641    /// Workspace `alice` creates `src/util.rs` with helper functions.
642    /// Workspace `bob` also creates `src/util.rs` with different helpers.
643    /// Both are add operations with no shared ancestor.
644    AddAdd {
645        /// Path where the file was independently added.
646        path: PathBuf,
647
648        /// The conflicting sides (one per workspace that added this path).
649        ///
650        /// Always has ≥ 2 entries. Sorted by workspace ID for determinism.
651        sides: Vec<ConflictSide>,
652    },
653
654    /// One workspace modified a file while another deleted it.
655    ///
656    /// This conflict requires a human/agent decision: keep the modified
657    /// version, accept the deletion, or do something else entirely.
658    ///
659    /// # Example
660    ///
661    /// Workspace `alice` refactors `src/old.rs` (modifies it).
662    /// Workspace `bob` deletes `src/old.rs` as part of a cleanup.
663    /// The merge engine cannot decide which intent should win.
664    ModifyDelete {
665        /// Path to the file that was both modified and deleted.
666        path: PathBuf,
667
668        /// Stable file identity.
669        file_id: FileId,
670
671        /// The workspace that modified the file (the "modify" side).
672        modifier: ConflictSide,
673
674        /// The workspace that deleted the file (the "delete" side).
675        ///
676        /// The `content` field of this side holds the last known blob OID
677        /// before deletion.
678        deleter: ConflictSide,
679
680        /// Git blob OID of the modified file content.
681        ///
682        /// This is the content from the `modifier` side, provided separately
683        /// for convenience so resolvers can inspect it without dereferencing
684        /// the side's content OID.
685        modified_content: GitOid,
686    },
687
688    /// The same file was renamed to different destinations by different
689    /// workspaces.
690    ///
691    /// The `FileId` is the same across all sides — only the destination paths
692    /// differ. Content may or may not have changed.
693    ///
694    /// # Example
695    ///
696    /// File `src/util.rs` (FileId=X) is renamed:
697    /// - Workspace `alice` renames to `src/helpers.rs`
698    /// - Workspace `bob` renames to `src/common.rs`
699    ///
700    /// Both operations share the same `FileId`, but the destinations diverge.
701    DivergentRename {
702        /// Stable file identity (same across all sides).
703        file_id: FileId,
704
705        /// Original path before any rename.
706        original: PathBuf,
707
708        /// The divergent rename destinations (one per workspace).
709        ///
710        /// Each entry is `(destination_path, conflict_side)`.
711        /// Sorted by destination path for determinism.
712        destinations: Vec<(PathBuf, ConflictSide)>,
713    },
714}
715
716impl Conflict {
717    /// Return the primary path associated with this conflict.
718    ///
719    /// For `DivergentRename`, returns the original path.
720    /// For all other variants, returns the conflict path.
721    #[must_use]
722    pub const fn path(&self) -> &PathBuf {
723        match self {
724            Self::Content { path, .. }
725            | Self::AddAdd { path, .. }
726            | Self::ModifyDelete { path, .. } => path,
727            Self::DivergentRename { original, .. } => original,
728        }
729    }
730
731    /// Return the conflict variant name as a static string.
732    #[must_use]
733    pub const fn variant_name(&self) -> &'static str {
734        match self {
735            Self::Content { .. } => "content",
736            Self::AddAdd { .. } => "add_add",
737            Self::ModifyDelete { .. } => "modify_delete",
738            Self::DivergentRename { .. } => "divergent_rename",
739        }
740    }
741
742    /// Return the number of sides involved in this conflict.
743    #[must_use]
744    pub const fn side_count(&self) -> usize {
745        match self {
746            Self::Content { sides, .. } | Self::AddAdd { sides, .. } => sides.len(),
747            Self::ModifyDelete { .. } => 2,
748            Self::DivergentRename { destinations, .. } => destinations.len(),
749        }
750    }
751
752    /// Return all workspace names involved in this conflict.
753    #[must_use]
754    pub fn workspaces(&self) -> Vec<&str> {
755        match self {
756            Self::Content { sides, .. } | Self::AddAdd { sides, .. } => {
757                sides.iter().map(|s| s.workspace.as_str()).collect()
758            }
759            Self::ModifyDelete {
760                modifier, deleter, ..
761            } => vec![modifier.workspace.as_str(), deleter.workspace.as_str()],
762            Self::DivergentRename { destinations, .. } => destinations
763                .iter()
764                .map(|(_, s)| s.workspace.as_str())
765                .collect(),
766        }
767    }
768}
769
770impl fmt::Display for Conflict {
771    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772        match self {
773            Self::Content {
774                path, sides, atoms, ..
775            } => {
776                let ws: Vec<_> = sides.iter().map(|s| s.workspace.as_str()).collect();
777                write!(
778                    f,
779                    "content conflict in {} between [{}] ({} atom(s))",
780                    path.display(),
781                    ws.join(", "),
782                    atoms.len()
783                )
784            }
785            Self::AddAdd { path, sides } => {
786                let ws: Vec<_> = sides.iter().map(|s| s.workspace.as_str()).collect();
787                write!(
788                    f,
789                    "add/add conflict at {} between [{}]",
790                    path.display(),
791                    ws.join(", ")
792                )
793            }
794            Self::ModifyDelete {
795                path,
796                modifier,
797                deleter,
798                ..
799            } => {
800                write!(
801                    f,
802                    "modify/delete conflict on {}: {} modified, {} deleted",
803                    path.display(),
804                    modifier.workspace,
805                    deleter.workspace
806                )
807            }
808            Self::DivergentRename {
809                original,
810                destinations,
811                ..
812            } => {
813                let dests: Vec<_> = destinations
814                    .iter()
815                    .map(|(p, s)| format!("{} → {}", s.workspace, p.display()))
816                    .collect();
817                write!(
818                    f,
819                    "divergent rename of {}: [{}]",
820                    original.display(),
821                    dests.join(", ")
822                )
823            }
824        }
825    }
826}
827
828// ---------------------------------------------------------------------------
829// Tests
830// ---------------------------------------------------------------------------
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835    use crate::model::types::EpochId;
836
837    // Helper to create a test GitOid
838    fn test_oid(c: char) -> GitOid {
839        GitOid::new(&c.to_string().repeat(40)).unwrap()
840    }
841
842    // Helper to create a test FileId
843    fn test_file_id(val: u128) -> FileId {
844        FileId::new(val)
845    }
846
847    // Helper to create a test OrderingKey
848    fn test_ordering_key(ws: &str, seq: u64) -> OrderingKey {
849        let epoch = EpochId::new(&"e".repeat(40)).unwrap();
850        OrderingKey::new(epoch, ws.parse().unwrap(), seq, 1_700_000_000_000)
851    }
852
853    // Helper to create a test ConflictSide
854    fn test_side(ws: &str, oid_char: char, seq: u64) -> ConflictSide {
855        ConflictSide::new(ws.into(), test_oid(oid_char), test_ordering_key(ws, seq))
856    }
857
858    // Helper to create a simple test ConflictAtom with a description
859    fn test_atom(desc: &str) -> ConflictAtom {
860        ConflictAtom::line_overlap(
861            1,
862            10,
863            vec![
864                AtomEdit::new("ws-1", Region::lines(1, 5), "side-1"),
865                AtomEdit::new("ws-2", Region::lines(5, 10), "side-2"),
866            ],
867            desc,
868        )
869    }
870
871    // -----------------------------------------------------------------------
872    // ConflictSide
873    // -----------------------------------------------------------------------
874
875    #[test]
876    fn conflict_side_construction() {
877        let side = test_side("alice", 'a', 1);
878        assert_eq!(side.workspace, "alice");
879        assert_eq!(side.content, test_oid('a'));
880        assert_eq!(side.timestamp.seq, 1);
881    }
882
883    #[test]
884    fn conflict_side_serde_roundtrip() {
885        let side = test_side("bob", 'b', 42);
886        let json = serde_json::to_string(&side).unwrap();
887        let decoded: ConflictSide = serde_json::from_str(&json).unwrap();
888        assert_eq!(decoded.workspace, side.workspace);
889        assert_eq!(decoded.content, side.content);
890        assert_eq!(decoded.timestamp.seq, side.timestamp.seq);
891    }
892
893    // -----------------------------------------------------------------------
894    // Region
895    // -----------------------------------------------------------------------
896
897    #[test]
898    fn region_lines_construction() {
899        let r = Region::lines(10, 15);
900        assert_eq!(r.summary(), "lines 10..15");
901        assert_eq!(format!("{r}"), "lines 10..15");
902    }
903
904    #[test]
905    fn region_ast_node_with_name() {
906        let r = Region::ast_node("function_item", Some("process_order".into()), 1024, 2048);
907        assert_eq!(r.summary(), "function_item `process_order`");
908    }
909
910    #[test]
911    fn region_ast_node_without_name() {
912        let r = Region::ast_node("struct_item", None, 0, 100);
913        assert_eq!(r.summary(), "struct_item");
914    }
915
916    #[test]
917    fn region_whole_file() {
918        let r = Region::whole_file();
919        assert_eq!(r.summary(), "whole file");
920    }
921
922    #[test]
923    fn region_lines_serde_roundtrip() {
924        let r = Region::lines(42, 67);
925        let json = serde_json::to_string(&r).unwrap();
926        assert!(json.contains("\"kind\":\"lines\""));
927        assert!(json.contains("\"start\":42"));
928        assert!(json.contains("\"end\":67"));
929        let decoded: Region = serde_json::from_str(&json).unwrap();
930        assert_eq!(decoded, r);
931    }
932
933    #[test]
934    fn region_ast_node_serde_roundtrip() {
935        let r = Region::ast_node("function_item", Some("foo".into()), 100, 200);
936        let json = serde_json::to_string(&r).unwrap();
937        assert!(json.contains("\"kind\":\"ast_node\""));
938        assert!(json.contains("\"node_kind\":\"function_item\""));
939        let decoded: Region = serde_json::from_str(&json).unwrap();
940        assert_eq!(decoded, r);
941    }
942
943    #[test]
944    fn region_whole_file_serde_roundtrip() {
945        let r = Region::whole_file();
946        let json = serde_json::to_string(&r).unwrap();
947        assert!(json.contains("\"kind\":\"whole_file\""));
948        let decoded: Region = serde_json::from_str(&json).unwrap();
949        assert_eq!(decoded, r);
950    }
951
952    // -----------------------------------------------------------------------
953    // ConflictReason
954    // -----------------------------------------------------------------------
955
956    #[test]
957    fn conflict_reason_overlapping() {
958        let r = ConflictReason::overlapping("lines 10-15 overlap in both sides");
959        assert_eq!(r.variant_name(), "overlapping_line_edits");
960        assert_eq!(r.description(), "lines 10-15 overlap in both sides");
961    }
962
963    #[test]
964    fn conflict_reason_same_ast_node() {
965        let r = ConflictReason::same_ast_node("function `foo` modified by both");
966        assert_eq!(r.variant_name(), "same_ast_node_modified");
967    }
968
969    #[test]
970    fn conflict_reason_non_commutative() {
971        let r =
972            ConflictReason::non_commutative("edits produce different results in different order");
973        assert_eq!(r.variant_name(), "non_commutative_edits");
974    }
975
976    #[test]
977    fn conflict_reason_custom() {
978        let r = ConflictReason::custom("custom driver reported conflict");
979        assert_eq!(r.variant_name(), "custom");
980        assert_eq!(r.description(), "custom driver reported conflict");
981    }
982
983    #[test]
984    fn conflict_reason_serde_roundtrip() {
985        let reasons = vec![
986            ConflictReason::overlapping("overlap"),
987            ConflictReason::same_ast_node("ast"),
988            ConflictReason::non_commutative("non-comm"),
989            ConflictReason::custom("custom"),
990        ];
991        for reason in &reasons {
992            let json = serde_json::to_string(reason).unwrap();
993            let decoded: ConflictReason = serde_json::from_str(&json).unwrap();
994            assert_eq!(decoded.variant_name(), reason.variant_name());
995            assert_eq!(decoded.description(), reason.description());
996        }
997    }
998
999    #[test]
1000    fn conflict_reason_display() {
1001        let r = ConflictReason::overlapping("test display");
1002        assert_eq!(format!("{r}"), "test display");
1003    }
1004
1005    // -----------------------------------------------------------------------
1006    // AtomEdit
1007    // -----------------------------------------------------------------------
1008
1009    #[test]
1010    fn atom_edit_construction() {
1011        let edit = AtomEdit::new("alice", Region::lines(10, 15), "fn foo() {}");
1012        assert_eq!(edit.workspace, "alice");
1013        assert_eq!(edit.region, Region::lines(10, 15));
1014        assert_eq!(edit.content, "fn foo() {}");
1015    }
1016
1017    #[test]
1018    fn atom_edit_serde_roundtrip() {
1019        let edit = AtomEdit::new("bob", Region::lines(20, 30), "new code here");
1020        let json = serde_json::to_string(&edit).unwrap();
1021        let decoded: AtomEdit = serde_json::from_str(&json).unwrap();
1022        assert_eq!(decoded, edit);
1023    }
1024
1025    #[test]
1026    fn atom_edit_display_short_content() {
1027        let edit = AtomEdit::new("ws-1", Region::lines(1, 5), "short");
1028        let display = format!("{edit}");
1029        assert!(display.contains("ws-1"));
1030        assert!(display.contains("lines 1..5"));
1031    }
1032
1033    #[test]
1034    fn atom_edit_display_long_content_truncated() {
1035        let long = "a".repeat(100);
1036        let edit = AtomEdit::new("ws-1", Region::lines(1, 5), long);
1037        let display = format!("{edit}");
1038        assert!(display.contains("..."));
1039    }
1040
1041    // -----------------------------------------------------------------------
1042    // ConflictAtom
1043    // -----------------------------------------------------------------------
1044
1045    #[test]
1046    fn conflict_atom_construction() {
1047        let atom = ConflictAtom::new(
1048            Region::lines(10, 15),
1049            vec![
1050                AtomEdit::new("alice", Region::lines(10, 13), "alice's code"),
1051                AtomEdit::new("bob", Region::lines(12, 15), "bob's code"),
1052            ],
1053            ConflictReason::overlapping("lines 10-15 overlap"),
1054        );
1055        assert_eq!(atom.base_region, Region::lines(10, 15));
1056        assert_eq!(atom.edits.len(), 2);
1057        assert_eq!(atom.reason.variant_name(), "overlapping_line_edits");
1058    }
1059
1060    #[test]
1061    fn conflict_atom_line_overlap_convenience() {
1062        let atom = ConflictAtom::line_overlap(
1063            42,
1064            67,
1065            vec![
1066                AtomEdit::new("ws-1", Region::lines(42, 55), "code-1"),
1067                AtomEdit::new("ws-2", Region::lines(50, 67), "code-2"),
1068            ],
1069            "Both sides edited lines 42-67",
1070        );
1071        assert_eq!(atom.base_region, Region::lines(42, 67));
1072        assert_eq!(atom.reason.variant_name(), "overlapping_line_edits");
1073    }
1074
1075    #[test]
1076    fn conflict_atom_serde_roundtrip() {
1077        let atom = ConflictAtom::new(
1078            Region::lines(1, 10),
1079            vec![
1080                AtomEdit::new("ws-a", Region::lines(1, 5), "alpha"),
1081                AtomEdit::new("ws-b", Region::lines(3, 10), "beta"),
1082            ],
1083            ConflictReason::overlapping("overlap at lines 3-5"),
1084        );
1085        let json = serde_json::to_string_pretty(&atom).unwrap();
1086        assert!(json.contains("\"base_region\""));
1087        assert!(json.contains("\"edits\""));
1088        assert!(json.contains("\"reason\""));
1089
1090        let decoded: ConflictAtom = serde_json::from_str(&json).unwrap();
1091        assert_eq!(decoded, atom);
1092    }
1093
1094    #[test]
1095    fn conflict_atom_with_ast_region() {
1096        let atom = ConflictAtom::new(
1097            Region::ast_node("function_item", Some("process_order".into()), 1024, 2048),
1098            vec![
1099                AtomEdit::new(
1100                    "alice",
1101                    Region::ast_node("function_item", Some("process_order".into()), 1024, 1800),
1102                    "alice version",
1103                ),
1104                AtomEdit::new(
1105                    "bob",
1106                    Region::ast_node("function_item", Some("process_order".into()), 1024, 1900),
1107                    "bob version",
1108                ),
1109            ],
1110            ConflictReason::same_ast_node("function `process_order` modified by both"),
1111        );
1112        assert_eq!(
1113            atom.summary(),
1114            "function_item `process_order` — function `process_order` modified by both [alice, bob]"
1115        );
1116    }
1117
1118    #[test]
1119    fn conflict_atom_summary() {
1120        let atom = ConflictAtom::line_overlap(
1121            10,
1122            20,
1123            vec![
1124                AtomEdit::new("ws-1", Region::lines(10, 15), ""),
1125                AtomEdit::new("ws-2", Region::lines(12, 20), ""),
1126            ],
1127            "overlap",
1128        );
1129        let summary = atom.summary();
1130        assert!(summary.contains("lines 10..20"));
1131        assert!(summary.contains("overlap"));
1132        assert!(summary.contains("ws-1"));
1133        assert!(summary.contains("ws-2"));
1134    }
1135
1136    #[test]
1137    fn conflict_atom_display() {
1138        let atom = ConflictAtom::line_overlap(
1139            1,
1140            5,
1141            vec![
1142                AtomEdit::new("a", Region::lines(1, 3), "x"),
1143                AtomEdit::new("b", Region::lines(2, 5), "y"),
1144            ],
1145            "test",
1146        );
1147        let display = format!("{atom}");
1148        assert!(display.contains("lines 1..5"));
1149    }
1150
1151    // -----------------------------------------------------------------------
1152    // Conflict::Content
1153    // -----------------------------------------------------------------------
1154
1155    #[test]
1156    fn content_conflict_with_base() {
1157        let conflict = Conflict::Content {
1158            path: "src/lib.rs".into(),
1159            file_id: test_file_id(1),
1160            base: Some(test_oid('0')),
1161            sides: vec![test_side("alice", 'a', 1), test_side("bob", 'b', 2)],
1162            atoms: vec![test_atom("lines 10-15")],
1163        };
1164
1165        assert_eq!(conflict.path(), &PathBuf::from("src/lib.rs"));
1166        assert_eq!(conflict.variant_name(), "content");
1167        assert_eq!(conflict.side_count(), 2);
1168        assert_eq!(conflict.workspaces(), vec!["alice", "bob"]);
1169    }
1170
1171    #[test]
1172    fn content_conflict_without_base() {
1173        let conflict = Conflict::Content {
1174            path: "src/new.rs".into(),
1175            file_id: test_file_id(2),
1176            base: None,
1177            sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1178            atoms: vec![],
1179        };
1180
1181        if let Conflict::Content { base, atoms, .. } = &conflict {
1182            assert!(base.is_none());
1183            assert!(atoms.is_empty());
1184        } else {
1185            panic!("expected Content variant");
1186        }
1187    }
1188
1189    #[test]
1190    fn content_conflict_three_way() {
1191        let conflict = Conflict::Content {
1192            path: "README.md".into(),
1193            file_id: test_file_id(3),
1194            base: Some(test_oid('0')),
1195            sides: vec![
1196                test_side("alice", 'a', 1),
1197                test_side("bob", 'b', 2),
1198                test_side("carol", 'c', 3),
1199            ],
1200            atoms: vec![test_atom("header section"), test_atom("footer section")],
1201        };
1202
1203        assert_eq!(conflict.side_count(), 3);
1204        assert_eq!(conflict.workspaces(), vec!["alice", "bob", "carol"]);
1205    }
1206
1207    #[test]
1208    fn content_conflict_serde_roundtrip() {
1209        let conflict = Conflict::Content {
1210            path: "src/main.rs".into(),
1211            file_id: test_file_id(10),
1212            base: Some(test_oid('0')),
1213            sides: vec![test_side("alice", 'a', 1), test_side("bob", 'b', 2)],
1214            atoms: vec![test_atom("imports block")],
1215        };
1216
1217        let json = serde_json::to_string_pretty(&conflict).unwrap();
1218        assert!(json.contains("\"type\": \"content\""));
1219        assert!(json.contains("\"path\": \"src/main.rs\""));
1220
1221        let decoded: Conflict = serde_json::from_str(&json).unwrap();
1222        assert_eq!(decoded.variant_name(), "content");
1223        assert_eq!(decoded.path(), &PathBuf::from("src/main.rs"));
1224    }
1225
1226    #[test]
1227    fn content_conflict_json_tag() {
1228        let conflict = Conflict::Content {
1229            path: "a.txt".into(),
1230            file_id: test_file_id(99),
1231            base: None,
1232            sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1233            atoms: vec![],
1234        };
1235        let json = serde_json::to_string(&conflict).unwrap();
1236        assert!(json.contains("\"type\":\"content\""));
1237    }
1238
1239    // -----------------------------------------------------------------------
1240    // Conflict::AddAdd
1241    // -----------------------------------------------------------------------
1242
1243    #[test]
1244    fn add_add_conflict() {
1245        let conflict = Conflict::AddAdd {
1246            path: "src/util.rs".into(),
1247            sides: vec![test_side("alice", 'a', 1), test_side("bob", 'b', 1)],
1248        };
1249
1250        assert_eq!(conflict.path(), &PathBuf::from("src/util.rs"));
1251        assert_eq!(conflict.variant_name(), "add_add");
1252        assert_eq!(conflict.side_count(), 2);
1253        assert_eq!(conflict.workspaces(), vec!["alice", "bob"]);
1254    }
1255
1256    #[test]
1257    fn add_add_conflict_serde_roundtrip() {
1258        let conflict = Conflict::AddAdd {
1259            path: "new-file.txt".into(),
1260            sides: vec![test_side("ws-a", 'a', 5), test_side("ws-b", 'b', 3)],
1261        };
1262
1263        let json = serde_json::to_string(&conflict).unwrap();
1264        assert!(json.contains("\"type\":\"add_add\""));
1265
1266        let decoded: Conflict = serde_json::from_str(&json).unwrap();
1267        assert_eq!(decoded.variant_name(), "add_add");
1268    }
1269
1270    // -----------------------------------------------------------------------
1271    // Conflict::ModifyDelete
1272    // -----------------------------------------------------------------------
1273
1274    #[test]
1275    fn modify_delete_conflict() {
1276        let conflict = Conflict::ModifyDelete {
1277            path: "src/old.rs".into(),
1278            file_id: test_file_id(42),
1279            modifier: test_side("alice", 'a', 5),
1280            deleter: test_side("bob", 'b', 6),
1281            modified_content: test_oid('a'),
1282        };
1283
1284        assert_eq!(conflict.path(), &PathBuf::from("src/old.rs"));
1285        assert_eq!(conflict.variant_name(), "modify_delete");
1286        assert_eq!(conflict.side_count(), 2);
1287        assert_eq!(conflict.workspaces(), vec!["alice", "bob"]);
1288    }
1289
1290    #[test]
1291    fn modify_delete_conflict_serde_roundtrip() {
1292        let conflict = Conflict::ModifyDelete {
1293            path: "docs/api.md".into(),
1294            file_id: test_file_id(100),
1295            modifier: test_side("dev-1", 'a', 10),
1296            deleter: test_side("dev-2", 'b', 11),
1297            modified_content: test_oid('a'),
1298        };
1299
1300        let json = serde_json::to_string_pretty(&conflict).unwrap();
1301        assert!(json.contains("\"type\": \"modify_delete\""));
1302
1303        let decoded: Conflict = serde_json::from_str(&json).unwrap();
1304        assert_eq!(decoded.variant_name(), "modify_delete");
1305        if let Conflict::ModifyDelete {
1306            modifier, deleter, ..
1307        } = &decoded
1308        {
1309            assert_eq!(modifier.workspace, "dev-1");
1310            assert_eq!(deleter.workspace, "dev-2");
1311        }
1312    }
1313
1314    // -----------------------------------------------------------------------
1315    // Conflict::DivergentRename
1316    // -----------------------------------------------------------------------
1317
1318    #[test]
1319    fn divergent_rename_conflict() {
1320        let conflict = Conflict::DivergentRename {
1321            file_id: test_file_id(77),
1322            original: "src/util.rs".into(),
1323            destinations: vec![
1324                ("src/helpers.rs".into(), test_side("alice", 'a', 1)),
1325                ("src/common.rs".into(), test_side("bob", 'b', 1)),
1326            ],
1327        };
1328
1329        assert_eq!(conflict.path(), &PathBuf::from("src/util.rs"));
1330        assert_eq!(conflict.variant_name(), "divergent_rename");
1331        assert_eq!(conflict.side_count(), 2);
1332        assert_eq!(conflict.workspaces(), vec!["alice", "bob"]);
1333    }
1334
1335    #[test]
1336    fn divergent_rename_three_way() {
1337        let conflict = Conflict::DivergentRename {
1338            file_id: test_file_id(88),
1339            original: "old.rs".into(),
1340            destinations: vec![
1341                ("new-a.rs".into(), test_side("ws-1", 'a', 1)),
1342                ("new-b.rs".into(), test_side("ws-2", 'b', 1)),
1343                ("new-c.rs".into(), test_side("ws-3", 'c', 1)),
1344            ],
1345        };
1346
1347        assert_eq!(conflict.side_count(), 3);
1348        assert_eq!(conflict.workspaces(), vec!["ws-1", "ws-2", "ws-3"]);
1349    }
1350
1351    #[test]
1352    fn divergent_rename_serde_roundtrip() {
1353        let conflict = Conflict::DivergentRename {
1354            file_id: test_file_id(55),
1355            original: "src/old.rs".into(),
1356            destinations: vec![
1357                ("src/new-a.rs".into(), test_side("alice", 'a', 3)),
1358                ("src/new-b.rs".into(), test_side("bob", 'b', 4)),
1359            ],
1360        };
1361
1362        let json = serde_json::to_string(&conflict).unwrap();
1363        assert!(json.contains("\"type\":\"divergent_rename\""));
1364
1365        let decoded: Conflict = serde_json::from_str(&json).unwrap();
1366        assert_eq!(decoded.variant_name(), "divergent_rename");
1367    }
1368
1369    // -----------------------------------------------------------------------
1370    // Display
1371    // -----------------------------------------------------------------------
1372
1373    #[test]
1374    fn display_content_conflict() {
1375        let conflict = Conflict::Content {
1376            path: "src/lib.rs".into(),
1377            file_id: test_file_id(1),
1378            base: Some(test_oid('0')),
1379            sides: vec![test_side("alice", 'a', 1), test_side("bob", 'b', 2)],
1380            atoms: vec![test_atom("line 10")],
1381        };
1382        let display = format!("{conflict}");
1383        assert!(display.contains("content conflict in src/lib.rs"));
1384        assert!(display.contains("alice"));
1385        assert!(display.contains("bob"));
1386        assert!(display.contains("1 atom(s)"));
1387    }
1388
1389    #[test]
1390    fn display_add_add_conflict() {
1391        let conflict = Conflict::AddAdd {
1392            path: "new.rs".into(),
1393            sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1394        };
1395        let display = format!("{conflict}");
1396        assert!(display.contains("add/add conflict at new.rs"));
1397    }
1398
1399    #[test]
1400    fn display_modify_delete_conflict() {
1401        let conflict = Conflict::ModifyDelete {
1402            path: "gone.rs".into(),
1403            file_id: test_file_id(9),
1404            modifier: test_side("alice", 'a', 1),
1405            deleter: test_side("bob", 'b', 2),
1406            modified_content: test_oid('a'),
1407        };
1408        let display = format!("{conflict}");
1409        assert!(display.contains("modify/delete"));
1410        assert!(display.contains("alice modified"));
1411        assert!(display.contains("bob deleted"));
1412    }
1413
1414    #[test]
1415    fn display_divergent_rename_conflict() {
1416        let conflict = Conflict::DivergentRename {
1417            file_id: test_file_id(7),
1418            original: "old.rs".into(),
1419            destinations: vec![
1420                ("new-a.rs".into(), test_side("alice", 'a', 1)),
1421                ("new-b.rs".into(), test_side("bob", 'b', 1)),
1422            ],
1423        };
1424        let display = format!("{conflict}");
1425        assert!(display.contains("divergent rename of old.rs"));
1426        assert!(display.contains("alice → new-a.rs"));
1427        assert!(display.contains("bob → new-b.rs"));
1428    }
1429
1430    // -----------------------------------------------------------------------
1431    // Cross-variant serialization
1432    // -----------------------------------------------------------------------
1433
1434    #[test]
1435    fn all_variants_deserialize_from_json() {
1436        let variants = vec![
1437            Conflict::Content {
1438                path: "a.rs".into(),
1439                file_id: test_file_id(1),
1440                base: Some(test_oid('0')),
1441                sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1442                atoms: vec![],
1443            },
1444            Conflict::AddAdd {
1445                path: "b.rs".into(),
1446                sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1447            },
1448            Conflict::ModifyDelete {
1449                path: "c.rs".into(),
1450                file_id: test_file_id(2),
1451                modifier: test_side("ws-1", 'a', 1),
1452                deleter: test_side("ws-2", 'b', 1),
1453                modified_content: test_oid('a'),
1454            },
1455            Conflict::DivergentRename {
1456                file_id: test_file_id(3),
1457                original: "d.rs".into(),
1458                destinations: vec![
1459                    ("e.rs".into(), test_side("ws-1", 'a', 1)),
1460                    ("f.rs".into(), test_side("ws-2", 'b', 1)),
1461                ],
1462            },
1463        ];
1464
1465        for variant in &variants {
1466            let json = serde_json::to_string(variant).unwrap();
1467            let decoded: Conflict = serde_json::from_str(&json).unwrap();
1468            assert_eq!(decoded.variant_name(), variant.variant_name());
1469        }
1470    }
1471
1472    #[test]
1473    fn conflict_json_keys_are_snake_case() {
1474        let conflict = Conflict::ModifyDelete {
1475            path: "test.rs".into(),
1476            file_id: test_file_id(1),
1477            modifier: test_side("ws-1", 'a', 1),
1478            deleter: test_side("ws-2", 'b', 1),
1479            modified_content: test_oid('a'),
1480        };
1481        let json = serde_json::to_string(&conflict).unwrap();
1482        assert!(json.contains("\"modified_content\""));
1483        assert!(json.contains("\"file_id\""));
1484        assert!(!json.contains("\"modifiedContent\""));
1485    }
1486
1487    #[test]
1488    fn variant_name_matches_serde_tag() {
1489        let cases: Vec<(Conflict, &str)> = vec![
1490            (
1491                Conflict::Content {
1492                    path: "a.rs".into(),
1493                    file_id: test_file_id(1),
1494                    base: None,
1495                    sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1496                    atoms: vec![],
1497                },
1498                "content",
1499            ),
1500            (
1501                Conflict::AddAdd {
1502                    path: "b.rs".into(),
1503                    sides: vec![test_side("ws-1", 'a', 1), test_side("ws-2", 'b', 1)],
1504                },
1505                "add_add",
1506            ),
1507            (
1508                Conflict::ModifyDelete {
1509                    path: "c.rs".into(),
1510                    file_id: test_file_id(2),
1511                    modifier: test_side("ws-1", 'a', 1),
1512                    deleter: test_side("ws-2", 'b', 1),
1513                    modified_content: test_oid('a'),
1514                },
1515                "modify_delete",
1516            ),
1517            (
1518                Conflict::DivergentRename {
1519                    file_id: test_file_id(3),
1520                    original: "d.rs".into(),
1521                    destinations: vec![("e.rs".into(), test_side("ws-1", 'a', 1))],
1522                },
1523                "divergent_rename",
1524            ),
1525        ];
1526
1527        for (conflict, expected_name) in cases {
1528            assert_eq!(conflict.variant_name(), expected_name);
1529            let json = serde_json::to_string(&conflict).unwrap();
1530            let expected_tag = format!("\"type\":\"{expected_name}\"");
1531            assert!(
1532                json.contains(&expected_tag),
1533                "JSON should contain {expected_tag}, got: {json}"
1534            );
1535        }
1536    }
1537}