1use std::collections::BTreeMap;
28use std::path::PathBuf;
29
30use crate::model::patch::FileId;
31use crate::model::types::{GitOid, WorkspaceId};
32
33use super::types::{ChangeKind, PatchSet};
34
35#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct PathEntry {
53 pub workspace_id: WorkspaceId,
55 pub kind: ChangeKind,
57 pub content: Option<Vec<u8>>,
59 pub file_id: Option<FileId>,
61 pub blob: Option<GitOid>,
64}
65
66impl PathEntry {
67 #[must_use]
69 pub const fn new(
70 workspace_id: WorkspaceId,
71 kind: ChangeKind,
72 content: Option<Vec<u8>>,
73 ) -> Self {
74 Self {
75 workspace_id,
76 kind,
77 content,
78 file_id: None,
79 blob: None,
80 }
81 }
82
83 #[must_use]
85 pub const fn with_identity(
86 workspace_id: WorkspaceId,
87 kind: ChangeKind,
88 content: Option<Vec<u8>>,
89 file_id: Option<FileId>,
90 blob: Option<GitOid>,
91 ) -> Self {
92 Self {
93 workspace_id,
94 kind,
95 content,
96 file_id,
97 blob,
98 }
99 }
100
101 #[must_use]
103 pub const fn is_deletion(&self) -> bool {
104 matches!(self.kind, ChangeKind::Deleted)
105 }
106}
107
108#[derive(Clone, Debug)]
117pub struct PartitionResult {
118 pub unique: Vec<(PathBuf, PathEntry)>,
123
124 pub shared: Vec<(PathBuf, Vec<PathEntry>)>,
130}
131
132impl PartitionResult {
133 #[must_use]
135 pub const fn unique_count(&self) -> usize {
136 self.unique.len()
137 }
138
139 #[must_use]
141 pub const fn shared_count(&self) -> usize {
142 self.shared.len()
143 }
144
145 #[must_use]
147 pub const fn total_path_count(&self) -> usize {
148 self.unique.len() + self.shared.len()
149 }
150
151 #[must_use]
153 pub const fn is_conflict_free(&self) -> bool {
154 self.shared.is_empty()
155 }
156}
157
158#[must_use]
182pub fn partition_by_path(patch_sets: &[PatchSet]) -> PartitionResult {
183 let mut index: BTreeMap<PathBuf, Vec<PathEntry>> = BTreeMap::new();
185
186 for ps in patch_sets {
187 for change in &ps.changes {
188 let entry = PathEntry::with_identity(
192 ps.workspace_id.clone(),
193 change.kind.clone(),
194 change.content.clone(),
195 change.file_id,
196 change.blob.clone(),
197 );
198 index.entry(change.path.clone()).or_default().push(entry);
199 }
200 }
201
202 let mut unique = Vec::new();
204 let mut shared = Vec::new();
205
206 for (path, mut entries) in index {
207 if entries.len() == 1 {
208 unique.push((path, entries.remove(0)));
210 } else {
211 entries.sort_by(|a, b| a.workspace_id.as_str().cmp(b.workspace_id.as_str()));
214 shared.push((path, entries));
215 }
216 }
217
218 PartitionResult { unique, shared }
221}
222
223#[cfg(test)]
228#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
229mod tests {
230 use super::*;
231 use crate::merge::types::{ChangeKind, FileChange, PatchSet};
232 use crate::model::types::{EpochId, WorkspaceId};
233
234 fn make_epoch() -> EpochId {
235 EpochId::new(&"a".repeat(40)).unwrap()
236 }
237
238 fn make_ws(name: &str) -> WorkspaceId {
239 WorkspaceId::new(name).unwrap()
240 }
241
242 fn make_change(path: &str, kind: ChangeKind, content: Option<&[u8]>) -> FileChange {
243 FileChange::new(PathBuf::from(path), kind, content.map(<[u8]>::to_vec))
244 }
245
246 #[test]
249 fn partition_empty_patch_sets() {
250 let result = partition_by_path(&[]);
251 assert_eq!(result.unique_count(), 0);
252 assert_eq!(result.shared_count(), 0);
253 assert_eq!(result.total_path_count(), 0);
254 assert!(result.is_conflict_free());
255 }
256
257 #[test]
258 fn partition_single_empty_workspace() {
259 let ps = PatchSet::new(make_ws("ws-a"), make_epoch(), vec![]);
260 let result = partition_by_path(&[ps]);
261 assert_eq!(result.total_path_count(), 0);
262 assert!(result.is_conflict_free());
263 }
264
265 #[test]
268 fn partition_disjoint_changes_all_unique() {
269 let ps_a = PatchSet::new(
270 make_ws("ws-a"),
271 make_epoch(),
272 vec![make_change("a.rs", ChangeKind::Added, Some(b"fn a() {}"))],
273 );
274 let ps_b = PatchSet::new(
275 make_ws("ws-b"),
276 make_epoch(),
277 vec![make_change("b.rs", ChangeKind::Added, Some(b"fn b() {}"))],
278 );
279
280 let result = partition_by_path(&[ps_a, ps_b]);
281
282 assert_eq!(result.unique_count(), 2);
283 assert_eq!(result.shared_count(), 0);
284 assert!(result.is_conflict_free());
285
286 let unique_paths: Vec<_> = result.unique.iter().map(|(p, _)| p.clone()).collect();
288 assert_eq!(
289 unique_paths,
290 vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]
291 );
292
293 assert_eq!(result.unique[0].1.workspace_id.as_str(), "ws-a");
295 assert_eq!(result.unique[1].1.workspace_id.as_str(), "ws-b");
296 }
297
298 #[test]
301 fn partition_shared_path() {
302 let ps_a = PatchSet::new(
303 make_ws("ws-a"),
304 make_epoch(),
305 vec![make_change("shared.rs", ChangeKind::Modified, Some(b"a"))],
306 );
307 let ps_b = PatchSet::new(
308 make_ws("ws-b"),
309 make_epoch(),
310 vec![make_change("shared.rs", ChangeKind::Modified, Some(b"b"))],
311 );
312
313 let result = partition_by_path(&[ps_a, ps_b]);
314
315 assert_eq!(result.unique_count(), 0);
316 assert_eq!(result.shared_count(), 1);
317 assert!(!result.is_conflict_free());
318
319 let (path, entries) = &result.shared[0];
320 assert_eq!(path, &PathBuf::from("shared.rs"));
321 assert_eq!(entries.len(), 2);
322 assert_eq!(entries[0].workspace_id.as_str(), "ws-a");
324 assert_eq!(entries[1].workspace_id.as_str(), "ws-b");
325 }
326
327 #[test]
330 fn partition_mixed_unique_and_shared() {
331 let ps_a = PatchSet::new(
332 make_ws("ws-a"),
333 make_epoch(),
334 vec![
335 make_change("only-a.rs", ChangeKind::Added, Some(b"a")),
336 make_change("shared.rs", ChangeKind::Modified, Some(b"ver-a")),
337 ],
338 );
339 let ps_b = PatchSet::new(
340 make_ws("ws-b"),
341 make_epoch(),
342 vec![
343 make_change("only-b.rs", ChangeKind::Deleted, None),
344 make_change("shared.rs", ChangeKind::Modified, Some(b"ver-b")),
345 ],
346 );
347
348 let result = partition_by_path(&[ps_a, ps_b]);
349
350 assert_eq!(result.unique_count(), 2);
351 assert_eq!(result.shared_count(), 1);
352 assert_eq!(result.total_path_count(), 3);
353
354 let unique_paths: Vec<_> = result.unique.iter().map(|(p, _)| p.clone()).collect();
356 assert_eq!(
357 unique_paths,
358 vec![PathBuf::from("only-a.rs"), PathBuf::from("only-b.rs")]
359 );
360
361 let (shared_path, entries) = &result.shared[0];
363 assert_eq!(shared_path, &PathBuf::from("shared.rs"));
364 assert_eq!(entries.len(), 2);
365 }
366
367 #[test]
370 fn partition_three_way_shared() {
371 let ps_a = PatchSet::new(
372 make_ws("ws-a"),
373 make_epoch(),
374 vec![make_change("config.toml", ChangeKind::Modified, Some(b"a"))],
375 );
376 let ps_b = PatchSet::new(
377 make_ws("ws-b"),
378 make_epoch(),
379 vec![make_change("config.toml", ChangeKind::Modified, Some(b"b"))],
380 );
381 let ps_c = PatchSet::new(
382 make_ws("ws-c"),
383 make_epoch(),
384 vec![make_change("config.toml", ChangeKind::Modified, Some(b"c"))],
385 );
386
387 let result = partition_by_path(&[ps_a, ps_b, ps_c]);
388
389 assert_eq!(result.shared_count(), 1);
390 let (_, entries) = &result.shared[0];
391 assert_eq!(entries.len(), 3);
392 assert_eq!(entries[0].workspace_id.as_str(), "ws-a");
394 assert_eq!(entries[1].workspace_id.as_str(), "ws-b");
395 assert_eq!(entries[2].workspace_id.as_str(), "ws-c");
396 }
397
398 #[test]
401 fn partition_five_way_mixed() {
402 let workspaces: Vec<PatchSet> = (0..5)
403 .map(|i| {
404 let ws = make_ws(&format!("ws-{i}"));
405 let mut changes = vec![
406 make_change(
408 &format!("unique-{i}.rs"),
409 ChangeKind::Added,
410 Some(format!("fn ws_{i}() {{}}").as_bytes()),
411 ),
412 ];
413 changes.push(make_change(
415 "shared.rs",
416 ChangeKind::Modified,
417 Some(format!("version {i}").as_bytes()),
418 ));
419 PatchSet::new(ws, make_epoch(), changes)
420 })
421 .collect();
422
423 let result = partition_by_path(&workspaces);
424
425 assert_eq!(result.unique_count(), 5, "5 unique files");
426 assert_eq!(result.shared_count(), 1, "1 shared file");
427 assert_eq!(result.total_path_count(), 6);
428
429 let (_, entries) = &result.shared[0];
430 assert_eq!(entries.len(), 5, "5 workspaces modified shared.rs");
431 }
432
433 #[test]
436 fn partition_preserves_deletion_info() {
437 let ps = PatchSet::new(
438 make_ws("ws-a"),
439 make_epoch(),
440 vec![make_change("gone.rs", ChangeKind::Deleted, None)],
441 );
442
443 let result = partition_by_path(&[ps]);
444
445 assert_eq!(result.unique_count(), 1);
446 let (path, entry) = &result.unique[0];
447 assert_eq!(path, &PathBuf::from("gone.rs"));
448 assert!(entry.is_deletion());
449 assert!(entry.content.is_none());
450 }
451
452 #[test]
455 fn partition_preserves_file_content() {
456 let content = b"hello world\nline 2\n";
457 let ps = PatchSet::new(
458 make_ws("ws-a"),
459 make_epoch(),
460 vec![make_change("hello.txt", ChangeKind::Added, Some(content))],
461 );
462
463 let result = partition_by_path(&[ps]);
464
465 let (_, entry) = &result.unique[0];
466 assert_eq!(entry.content.as_deref(), Some(content.as_ref()));
467 }
468
469 #[test]
472 fn partition_paths_are_lexicographic() {
473 let ps = PatchSet::new(
474 make_ws("ws-a"),
475 make_epoch(),
476 vec![
477 make_change("z.rs", ChangeKind::Added, Some(b"")),
478 make_change("a.rs", ChangeKind::Added, Some(b"")),
479 make_change("m/deep.rs", ChangeKind::Added, Some(b"")),
480 make_change("b.rs", ChangeKind::Added, Some(b"")),
481 ],
482 );
483
484 let result = partition_by_path(&[ps]);
485
486 let paths: Vec<_> = result.unique.iter().map(|(p, _)| p.clone()).collect();
487 assert_eq!(
488 paths,
489 vec![
490 PathBuf::from("a.rs"),
491 PathBuf::from("b.rs"),
492 PathBuf::from("m/deep.rs"),
493 PathBuf::from("z.rs"),
494 ]
495 );
496 }
497
498 #[test]
501 fn partition_modify_delete_is_shared() {
502 let ps_a = PatchSet::new(
503 make_ws("ws-a"),
504 make_epoch(),
505 vec![make_change("file.rs", ChangeKind::Modified, Some(b"new"))],
506 );
507 let ps_b = PatchSet::new(
508 make_ws("ws-b"),
509 make_epoch(),
510 vec![make_change("file.rs", ChangeKind::Deleted, None)],
511 );
512
513 let result = partition_by_path(&[ps_a, ps_b]);
514
515 assert_eq!(result.shared_count(), 1);
516 let (_, entries) = &result.shared[0];
517 assert_eq!(entries.len(), 2);
518 assert!(matches!(entries[0].kind, ChangeKind::Modified));
519 assert!(matches!(entries[1].kind, ChangeKind::Deleted));
520 }
521
522 #[test]
525 fn partition_add_add_is_shared() {
526 let ps_a = PatchSet::new(
527 make_ws("ws-a"),
528 make_epoch(),
529 vec![make_change("new.rs", ChangeKind::Added, Some(b"version a"))],
530 );
531 let ps_b = PatchSet::new(
532 make_ws("ws-b"),
533 make_epoch(),
534 vec![make_change("new.rs", ChangeKind::Added, Some(b"version b"))],
535 );
536
537 let result = partition_by_path(&[ps_a, ps_b]);
538
539 assert_eq!(result.unique_count(), 0);
540 assert_eq!(result.shared_count(), 1);
541 let (_, entries) = &result.shared[0];
542 assert_eq!(entries.len(), 2);
543 assert!(matches!(entries[0].kind, ChangeKind::Added));
544 assert!(matches!(entries[1].kind, ChangeKind::Added));
545 }
546
547 #[test]
550 fn partition_delete_delete_is_shared() {
551 let ps_a = PatchSet::new(
552 make_ws("ws-a"),
553 make_epoch(),
554 vec![make_change("old.rs", ChangeKind::Deleted, None)],
555 );
556 let ps_b = PatchSet::new(
557 make_ws("ws-b"),
558 make_epoch(),
559 vec![make_change("old.rs", ChangeKind::Deleted, None)],
560 );
561
562 let result = partition_by_path(&[ps_a, ps_b]);
563
564 assert_eq!(result.shared_count(), 1);
565 let (_, entries) = &result.shared[0];
566 assert_eq!(entries.len(), 2);
567 assert!(entries.iter().all(super::PathEntry::is_deletion));
569 }
570
571 #[test]
574 fn path_entry_is_deletion() {
575 let del = PathEntry::new(make_ws("ws"), ChangeKind::Deleted, None);
576 assert!(del.is_deletion());
577
578 let add = PathEntry::new(make_ws("ws"), ChangeKind::Added, Some(vec![]));
579 assert!(!add.is_deletion());
580 }
581
582 fn make_change_with_identity(
588 path: &str,
589 kind: ChangeKind,
590 content: Option<&[u8]>,
591 file_id: crate::model::patch::FileId,
592 blob_hex: Option<&str>,
593 ) -> FileChange {
594 let blob = blob_hex.and_then(|h| crate::model::types::GitOid::new(h).ok());
595 FileChange::with_identity(
596 PathBuf::from(path),
597 kind,
598 content.map(<[u8]>::to_vec),
599 Some(file_id),
600 blob,
601 )
602 }
603
604 #[test]
607 fn partition_propagates_file_id_and_blob_to_path_entry() {
608 use crate::model::patch::FileId;
609
610 let fid = FileId::new(0xdead_beef_cafe_babe_1234_5678_9abc_def0);
611 let blob_hex = "a".repeat(40);
612
613 let change = make_change_with_identity(
614 "src/lib.rs",
615 ChangeKind::Modified,
616 Some(b"fn lib() {}"),
617 fid,
618 Some(&blob_hex),
619 );
620 let ps = PatchSet::new(make_ws("ws-a"), make_epoch(), vec![change]);
621
622 let result = partition_by_path(&[ps]);
623
624 assert_eq!(result.unique_count(), 1);
626 let (path, entry) = &result.unique[0];
627 assert_eq!(path, &PathBuf::from("src/lib.rs"));
628 assert_eq!(
629 entry.file_id,
630 Some(fid),
631 "FileId should propagate from FileChange to PathEntry"
632 );
633 assert!(
634 entry.blob.is_some(),
635 "blob OID should propagate from FileChange to PathEntry"
636 );
637 }
638
639 #[test]
641 fn partition_propagates_identity_into_shared_entries() {
642 use crate::model::patch::FileId;
643
644 let fid_a = FileId::new(1);
645 let fid_b = FileId::new(2);
646 let blob_a = "a".repeat(40);
647 let blob_b = "b".repeat(40);
648
649 let change_a = make_change_with_identity(
650 "shared.rs",
651 ChangeKind::Modified,
652 Some(b"version A"),
653 fid_a,
654 Some(&blob_a),
655 );
656 let change_b = make_change_with_identity(
657 "shared.rs",
658 ChangeKind::Modified,
659 Some(b"version B"),
660 fid_b,
661 Some(&blob_b),
662 );
663
664 let ps_a = PatchSet::new(make_ws("ws-a"), make_epoch(), vec![change_a]);
665 let ps_b = PatchSet::new(make_ws("ws-b"), make_epoch(), vec![change_b]);
666
667 let result = partition_by_path(&[ps_a, ps_b]);
668 assert_eq!(result.shared_count(), 1);
669
670 let (_, entries) = &result.shared[0];
671 assert_eq!(entries.len(), 2);
672
673 let entry_a = entries
675 .iter()
676 .find(|e| e.workspace_id.as_str() == "ws-a")
677 .unwrap();
678 let entry_b = entries
679 .iter()
680 .find(|e| e.workspace_id.as_str() == "ws-b")
681 .unwrap();
682
683 assert_eq!(entry_a.file_id, Some(fid_a));
684 assert_eq!(entry_b.file_id, Some(fid_b));
685 assert!(entry_a.blob.is_some());
686 assert!(entry_b.blob.is_some());
687 assert_ne!(entry_a.blob, entry_b.blob);
689 }
690
691 #[test]
693 fn partition_phase1_change_has_no_identity_in_path_entry() {
694 let change = make_change("old_style.rs", ChangeKind::Added, Some(b"fn old() {}"));
695 let ps = PatchSet::new(make_ws("ws-legacy"), make_epoch(), vec![change]);
696 let result = partition_by_path(&[ps]);
697
698 let (_, entry) = &result.unique[0];
699 assert!(
700 entry.file_id.is_none(),
701 "Phase 1 FileChange should produce PathEntry with no FileId"
702 );
703 assert!(
704 entry.blob.is_none(),
705 "Phase 1 FileChange should produce PathEntry with no blob OID"
706 );
707 }
708}