1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ConflictSide {
71 pub workspace: String,
73
74 pub content: GitOid,
76
77 pub timestamp: OrderingKey,
81}
82
83impl ConflictSide {
84 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(tag = "kind", rename_all = "snake_case")]
115pub enum Region {
116 Lines {
122 start: u32,
124 end: u32,
126 },
127
128 AstNode {
139 node_kind: String,
141 name: Option<String>,
143 start_byte: u32,
145 end_byte: u32,
147 },
148
149 WholeFile,
151}
152
153impl Region {
154 #[must_use]
156 pub const fn lines(start: u32, end: u32) -> Self {
157 Self::Lines { start, end }
158 }
159
160 #[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 #[must_use]
178 pub const fn whole_file() -> Self {
179 Self::WholeFile
180 }
181
182 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(tag = "reason", rename_all = "snake_case")]
222pub enum ConflictReason {
223 OverlappingLineEdits {
228 description: String,
230 },
231
232 SameAstNodeModified {
237 description: String,
239 },
240
241 SymbolLifecycleDivergence {
243 description: String,
245 },
246
247 SignatureDrift {
249 description: String,
251 },
252
253 IncompatibleApiEdits {
255 description: String,
257 },
258
259 NonCommutativeEdits {
265 description: String,
267 },
268
269 Custom {
273 description: String,
275 },
276}
277
278impl ConflictReason {
279 #[must_use]
281 pub fn overlapping(description: impl Into<String>) -> Self {
282 Self::OverlappingLineEdits {
283 description: description.into(),
284 }
285 }
286
287 #[must_use]
289 pub fn same_ast_node(description: impl Into<String>) -> Self {
290 Self::SameAstNodeModified {
291 description: description.into(),
292 }
293 }
294
295 #[must_use]
297 pub fn symbol_lifecycle(description: impl Into<String>) -> Self {
298 Self::SymbolLifecycleDivergence {
299 description: description.into(),
300 }
301 }
302
303 #[must_use]
305 pub fn signature_drift(description: impl Into<String>) -> Self {
306 Self::SignatureDrift {
307 description: description.into(),
308 }
309 }
310
311 #[must_use]
313 pub fn incompatible_api_edits(description: impl Into<String>) -> Self {
314 Self::IncompatibleApiEdits {
315 description: description.into(),
316 }
317 }
318
319 #[must_use]
321 pub fn non_commutative(description: impl Into<String>) -> Self {
322 Self::NonCommutativeEdits {
323 description: description.into(),
324 }
325 }
326
327 #[must_use]
329 pub fn custom(description: impl Into<String>) -> Self {
330 Self::Custom {
331 description: description.into(),
332 }
333 }
334
335 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
386pub struct AtomEdit {
387 pub workspace: String,
389
390 pub region: Region,
392
393 pub content: String,
397}
398
399impl AtomEdit {
400 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct SemanticConflictExplanation {
429 pub rule: String,
431 pub confidence: u8,
433 pub rationale: String,
435 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
486pub struct ConflictAtom {
487 pub base_region: Region,
489
490 pub edits: Vec<AtomEdit>,
495
496 pub reason: ConflictReason,
498
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub semantic: Option<SemanticConflictExplanation>,
502}
503
504impl ConflictAtom {
505 #[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 #[must_use]
518 pub fn with_semantic(mut self, semantic: SemanticConflictExplanation) -> Self {
519 self.semantic = Some(semantic);
520 self
521 }
522
523 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
593#[serde(tag = "type", rename_all = "snake_case")]
594pub enum Conflict {
595 Content {
608 path: PathBuf,
610
611 file_id: FileId,
613
614 base: Option<GitOid>,
620
621 sides: Vec<ConflictSide>,
625
626 atoms: Vec<ConflictAtom>,
631 },
632
633 AddAdd {
645 path: PathBuf,
647
648 sides: Vec<ConflictSide>,
652 },
653
654 ModifyDelete {
665 path: PathBuf,
667
668 file_id: FileId,
670
671 modifier: ConflictSide,
673
674 deleter: ConflictSide,
679
680 modified_content: GitOid,
686 },
687
688 DivergentRename {
702 file_id: FileId,
704
705 original: PathBuf,
707
708 destinations: Vec<(PathBuf, ConflictSide)>,
713 },
714}
715
716impl Conflict {
717 #[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 #[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 #[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 #[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#[cfg(test)]
833mod tests {
834 use super::*;
835 use crate::model::types::EpochId;
836
837 fn test_oid(c: char) -> GitOid {
839 GitOid::new(&c.to_string().repeat(40)).unwrap()
840 }
841
842 fn test_file_id(val: u128) -> FileId {
844 FileId::new(val)
845 }
846
847 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}