Skip to main content

maw/merge/
types.rs

1//! Core types for the N-way merge engine.
2//!
3//! Defines the data structures that flow through the collect → partition →
4//! resolve → build pipeline.
5
6use std::path::PathBuf;
7
8use crate::model::patch::FileId;
9use crate::model::types::{EpochId, GitOid, WorkspaceId};
10
11// ---------------------------------------------------------------------------
12// ChangeKind
13// ---------------------------------------------------------------------------
14
15/// The kind of change made to a file in a workspace.
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub enum ChangeKind {
18    /// File was newly added (did not exist at the epoch base).
19    Added,
20    /// File was modified (existed at the epoch base, content changed).
21    Modified,
22    /// File was deleted (existed at the epoch base, removed in workspace).
23    Deleted,
24}
25
26impl std::fmt::Display for ChangeKind {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Added => write!(f, "added"),
30            Self::Modified => write!(f, "modified"),
31            Self::Deleted => write!(f, "deleted"),
32        }
33    }
34}
35
36// ---------------------------------------------------------------------------
37// FileChange
38// ---------------------------------------------------------------------------
39
40/// A single file change captured from a workspace.
41///
42/// For `Added` and `Modified` changes, `content` holds the new file bytes.
43/// For `Deleted` changes, `content` is `None`.
44///
45/// `file_id` is the stable [`FileId`] assigned when the file was created. It
46/// survives renames and modifications, enabling rename-aware merge (§5.8).
47/// `None` if `FileId` tracking was not available at collect time.
48///
49/// `blob` is the git blob OID for the new content (computed via
50/// `git hash-object`). Present for `Added` and `Modified` changes when the
51/// collect step had access to the git repo. Enables O(1) hash-equality checks
52/// in the resolve step without comparing raw bytes.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct FileChange {
55    /// Path relative to the workspace root (and to the repo root).
56    pub path: PathBuf,
57    /// Type of change.
58    pub kind: ChangeKind,
59    /// New file content (`None` for deletions).
60    pub content: Option<Vec<u8>>,
61    /// Stable file identity that persists across renames (§5.8).
62    ///
63    /// `None` only for legacy artifacts and explicit test fixtures.
64    pub file_id: Option<FileId>,
65    /// Git blob OID for the new content (present for Add/Modify; `None` for
66    /// Delete and for changes collected without git access).
67    ///
68    /// When populated, the resolve step uses OID equality instead of byte
69    /// comparison for hash-equality checks, which is both faster and avoids
70    /// loading content into memory.
71    pub blob: Option<GitOid>,
72}
73
74impl FileChange {
75    /// Create a new `FileChange` without `FileId` or blob OID metadata.
76    ///
77    /// Suitable for explicit legacy/test fixtures. Production collect paths
78    /// should prefer [`FileChange::with_identity`].
79    #[must_use]
80    pub const fn new(path: PathBuf, kind: ChangeKind, content: Option<Vec<u8>>) -> Self {
81        Self {
82            path,
83            kind,
84            content,
85            file_id: None,
86            blob: None,
87        }
88    }
89
90    /// Create a new `FileChange` with full identity metadata.
91    ///
92    /// Preferred constructor for Phase 3+ code paths where `file_id` and
93    /// `blob` OID are available from the workspace's `FileId` map and git
94    /// object store.
95    #[must_use]
96    pub const fn with_identity(
97        path: PathBuf,
98        kind: ChangeKind,
99        content: Option<Vec<u8>>,
100        file_id: Option<FileId>,
101        blob: Option<GitOid>,
102    ) -> Self {
103        Self {
104            path,
105            kind,
106            content,
107            file_id,
108            blob,
109        }
110    }
111
112    /// Returns `true` if this change is a deletion.
113    #[must_use]
114    pub const fn is_deletion(&self) -> bool {
115        matches!(self.kind, ChangeKind::Deleted)
116    }
117
118    /// Returns `true` if this change adds or modifies a file (has content).
119    #[must_use]
120    pub const fn has_content(&self) -> bool {
121        self.content.is_some()
122    }
123}
124
125// ---------------------------------------------------------------------------
126// PatchSet
127// ---------------------------------------------------------------------------
128
129/// All changes from a single workspace relative to the epoch base.
130///
131/// Changes are sorted by path on construction for determinism.
132/// An empty `PatchSet` represents a workspace with no changes — these
133/// are included in collect output (not skipped) so the caller can
134/// handle them explicitly.
135#[derive(Clone, Debug)]
136pub struct PatchSet {
137    /// The workspace these changes came from.
138    pub workspace_id: WorkspaceId,
139    /// The epoch commit this workspace is based on.
140    pub epoch: EpochId,
141    /// File changes sorted by path for determinism.
142    pub changes: Vec<FileChange>,
143}
144
145impl PatchSet {
146    /// Create a new `PatchSet`, sorting changes by path for determinism.
147    #[must_use]
148    pub fn new(workspace_id: WorkspaceId, epoch: EpochId, mut changes: Vec<FileChange>) -> Self {
149        // Lexicographic sort by path ensures determinism regardless of insertion order.
150        changes.sort_by(|a, b| a.path.cmp(&b.path));
151        Self {
152            workspace_id,
153            epoch,
154            changes,
155        }
156    }
157
158    /// Returns `true` if there are no changes.
159    #[must_use]
160    pub const fn is_empty(&self) -> bool {
161        self.changes.is_empty()
162    }
163
164    /// Total count of all changes.
165    #[must_use]
166    pub const fn change_count(&self) -> usize {
167        self.changes.len()
168    }
169
170    /// Count of added files.
171    #[must_use]
172    pub fn added_count(&self) -> usize {
173        self.changes
174            .iter()
175            .filter(|c| matches!(c.kind, ChangeKind::Added))
176            .count()
177    }
178
179    /// Count of modified files.
180    #[must_use]
181    pub fn modified_count(&self) -> usize {
182        self.changes
183            .iter()
184            .filter(|c| matches!(c.kind, ChangeKind::Modified))
185            .count()
186    }
187
188    /// Count of deleted files.
189    #[must_use]
190    pub fn deleted_count(&self) -> usize {
191        self.changes
192            .iter()
193            .filter(|c| matches!(c.kind, ChangeKind::Deleted))
194            .count()
195    }
196
197    /// Returns `true` if this workspace only has deletions (no additions or modifications).
198    ///
199    /// Useful for the caller to detect deletion-only workspaces, which are
200    /// valid but may require special treatment in merge resolution.
201    #[must_use]
202    pub fn is_deletion_only(&self) -> bool {
203        !self.is_empty()
204            && self
205                .changes
206                .iter()
207                .all(|c| matches!(c.kind, ChangeKind::Deleted))
208    }
209
210    /// Iterate over changed paths.
211    pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
212        self.changes.iter().map(|c| &c.path)
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Tests
218// ---------------------------------------------------------------------------
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::model::types::{EpochId, WorkspaceId};
224
225    fn make_epoch() -> EpochId {
226        EpochId::new(&"a".repeat(40)).unwrap()
227    }
228
229    fn make_ws() -> WorkspaceId {
230        WorkspaceId::new("test-ws").unwrap()
231    }
232
233    #[test]
234    fn change_kind_display() {
235        assert_eq!(format!("{}", ChangeKind::Added), "added");
236        assert_eq!(format!("{}", ChangeKind::Modified), "modified");
237        assert_eq!(format!("{}", ChangeKind::Deleted), "deleted");
238    }
239
240    #[test]
241    fn file_change_deletion_has_no_content() {
242        let fc = FileChange::new(PathBuf::from("gone.rs"), ChangeKind::Deleted, None);
243        assert!(fc.is_deletion());
244        assert!(!fc.has_content());
245    }
246
247    #[test]
248    fn file_change_add_has_content() {
249        let fc = FileChange::new(
250            PathBuf::from("new.rs"),
251            ChangeKind::Added,
252            Some(b"fn main() {}".to_vec()),
253        );
254        assert!(!fc.is_deletion());
255        assert!(fc.has_content());
256    }
257
258    #[test]
259    fn patch_set_empty() {
260        let ps = PatchSet::new(make_ws(), make_epoch(), vec![]);
261        assert!(ps.is_empty());
262        assert_eq!(ps.change_count(), 0);
263        assert!(!ps.is_deletion_only());
264    }
265
266    #[test]
267    fn patch_set_sorts_by_path() {
268        let changes = vec![
269            FileChange::new(PathBuf::from("z.rs"), ChangeKind::Added, Some(vec![])),
270            FileChange::new(PathBuf::from("a.rs"), ChangeKind::Added, Some(vec![])),
271            FileChange::new(PathBuf::from("m.rs"), ChangeKind::Modified, Some(vec![])),
272        ];
273        let ps = PatchSet::new(make_ws(), make_epoch(), changes);
274        let paths: Vec<_> = ps.paths().collect();
275        assert_eq!(paths[0], &PathBuf::from("a.rs"));
276        assert_eq!(paths[1], &PathBuf::from("m.rs"));
277        assert_eq!(paths[2], &PathBuf::from("z.rs"));
278    }
279
280    #[test]
281    fn patch_set_deletion_only() {
282        let changes = vec![
283            FileChange::new(PathBuf::from("old.rs"), ChangeKind::Deleted, None),
284            FileChange::new(PathBuf::from("other.rs"), ChangeKind::Deleted, None),
285        ];
286        let ps = PatchSet::new(make_ws(), make_epoch(), changes);
287        assert!(ps.is_deletion_only());
288        assert!(!ps.is_empty());
289        assert_eq!(ps.deleted_count(), 2);
290    }
291
292    #[test]
293    fn patch_set_mixed_not_deletion_only() {
294        let changes = vec![
295            FileChange::new(PathBuf::from("old.rs"), ChangeKind::Deleted, None),
296            FileChange::new(PathBuf::from("new.rs"), ChangeKind::Added, Some(vec![])),
297        ];
298        let ps = PatchSet::new(make_ws(), make_epoch(), changes);
299        assert!(!ps.is_deletion_only());
300    }
301
302    #[test]
303    fn patch_set_counts() {
304        let changes = vec![
305            FileChange::new(PathBuf::from("add.rs"), ChangeKind::Added, Some(vec![])),
306            FileChange::new(PathBuf::from("add2.rs"), ChangeKind::Added, Some(vec![])),
307            FileChange::new(PathBuf::from("mod.rs"), ChangeKind::Modified, Some(vec![])),
308            FileChange::new(PathBuf::from("del.rs"), ChangeKind::Deleted, None),
309        ];
310        let ps = PatchSet::new(make_ws(), make_epoch(), changes);
311        assert_eq!(ps.added_count(), 2);
312        assert_eq!(ps.modified_count(), 1);
313        assert_eq!(ps.deleted_count(), 1);
314        assert_eq!(ps.change_count(), 4);
315    }
316}