Skip to main content

maw/model/
patch.rs

1//! Patch-set model — core data types (§5.4, §5.8).
2//!
3//! Instead of storing a full git tree per workspace, Manifold records only
4//! the files that changed. This makes workspace state proportional to
5//! *changed files*, not repo size.
6//!
7//! Key types:
8//! - [`FileId`] — stable identity that survives renames (§5.8)
9//! - [`PatchSet`] — epoch + `BTreeMap` of path → change
10//! - [`PatchValue`] — the four kinds of change (Add, Delete, Modify, Rename)
11
12use std::collections::BTreeMap;
13use std::fmt;
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use super::types::{EpochId, GitOid};
19
20// ---------------------------------------------------------------------------
21// FileId
22// ---------------------------------------------------------------------------
23
24/// A stable file identity that persists across renames and moves (§5.8).
25///
26/// A `FileId` is assigned when a file is first created and never changes,
27/// even if the file is renamed or moved. This makes rename-aware merge
28/// possible without heuristics.
29///
30/// Internally stored as a `u128` and serialized as a 32-character lowercase
31/// hex string for canonical JSON.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
33#[serde(try_from = "String", into = "String")]
34pub struct FileId(u128);
35
36impl FileId {
37    /// Create a `FileId` from a raw `u128`.
38    #[must_use]
39    pub const fn new(id: u128) -> Self {
40        Self(id)
41    }
42
43    /// Generate a cryptographically-random `FileId`.
44    ///
45    /// Uses the thread-local PRNG (rand 0.9). Each call produces a unique
46    /// 128-bit random identifier suitable for stable file identity.
47    #[must_use]
48    pub fn random() -> Self {
49        Self(rand::random::<u128>())
50    }
51
52    /// Return the inner `u128` value.
53    #[must_use]
54    pub const fn as_u128(self) -> u128 {
55        self.0
56    }
57
58    /// Parse a `FileId` from a 32-character lowercase hex string.
59    ///
60    /// # Errors
61    /// Returns an error if the string is not exactly 32 lowercase hex digits.
62    pub fn from_hex(s: &str) -> Result<Self, FileIdError> {
63        if s.len() != 32 {
64            return Err(FileIdError {
65                value: s.to_owned(),
66                reason: format!("expected 32 hex characters, got {}", s.len()),
67            });
68        }
69        if !s
70            .chars()
71            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
72        {
73            return Err(FileIdError {
74                value: s.to_owned(),
75                reason: "must contain only lowercase hex characters (0-9, a-f)".to_owned(),
76            });
77        }
78        let n = u128::from_str_radix(s, 16).map_err(|e| FileIdError {
79            value: s.to_owned(),
80            reason: e.to_string(),
81        })?;
82        Ok(Self(n))
83    }
84
85    /// Return a 32-character lowercase hex representation of this `FileId`.
86    #[must_use]
87    pub fn to_hex(self) -> String {
88        format!("{:032x}", self.0)
89    }
90}
91
92impl fmt::Display for FileId {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{:032x}", self.0)
95    }
96}
97
98impl TryFrom<String> for FileId {
99    type Error = FileIdError;
100    fn try_from(s: String) -> Result<Self, Self::Error> {
101        Self::from_hex(&s)
102    }
103}
104
105impl From<FileId> for String {
106    fn from(id: FileId) -> Self {
107        id.to_hex()
108    }
109}
110
111/// Error returned when a `FileId` string is malformed.
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct FileIdError {
114    /// The invalid value.
115    pub value: String,
116    /// Human-readable explanation.
117    pub reason: String,
118}
119
120impl fmt::Display for FileIdError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(f, "invalid FileId: {:?} — {}", self.value, self.reason)
123    }
124}
125
126impl std::error::Error for FileIdError {}
127
128// ---------------------------------------------------------------------------
129// PatchSet
130// ---------------------------------------------------------------------------
131
132/// A workspace's changed state relative to a base epoch (§5.4).
133///
134/// A `PatchSet` records only the files that changed between the base epoch
135/// and the current working directory. Snapshot cost is O(changed files), not
136/// O(repo size).
137///
138/// The `patches` map uses [`BTreeMap`] to guarantee **deterministic iteration
139/// order** for canonical JSON serialization and hashing.
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141pub struct PatchSet {
142    /// The epoch these patches are relative to.
143    pub base_epoch: EpochId,
144    /// Changed paths, sorted for determinism.
145    pub patches: BTreeMap<PathBuf, PatchValue>,
146}
147
148impl PatchSet {
149    /// Create an empty `PatchSet` relative to the given epoch.
150    #[must_use]
151    pub const fn empty(base_epoch: EpochId) -> Self {
152        Self {
153            base_epoch,
154            patches: BTreeMap::new(),
155        }
156    }
157
158    /// Return `true` if no paths are changed.
159    #[must_use]
160    pub fn is_empty(&self) -> bool {
161        self.patches.is_empty()
162    }
163
164    /// Return the number of changed paths.
165    #[must_use]
166    pub fn len(&self) -> usize {
167        self.patches.len()
168    }
169}
170
171// ---------------------------------------------------------------------------
172// PatchValue
173// ---------------------------------------------------------------------------
174
175/// The change applied to a single path within a [`PatchSet`] (§5.4).
176///
177/// Serialized with a `"op"` tag for canonical JSON:
178/// `{"op":"add","blob":"…","file_id":"…"}` etc.
179#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(tag = "op", rename_all = "snake_case")]
181pub enum PatchValue {
182    /// File was created at this path.
183    Add {
184        /// Blob OID for the new file content.
185        blob: GitOid,
186        /// Stable identity assigned at file creation.
187        file_id: FileId,
188    },
189    /// File was removed from this path.
190    Delete {
191        /// Blob OID the file had before deletion (needed for undo).
192        previous_blob: GitOid,
193        /// Stable identity of the deleted file.
194        file_id: FileId,
195    },
196    /// File content was changed in place.
197    Modify {
198        /// Blob OID the file had before the modification (needed for undo).
199        base_blob: GitOid,
200        /// Blob OID for the new file content.
201        new_blob: GitOid,
202        /// Stable file identity (unchanged by a modify).
203        file_id: FileId,
204    },
205    /// File was moved (and optionally also modified).
206    ///
207    /// The path key in [`PatchSet::patches`] is the **destination** path.
208    /// `from` records the **source** path.
209    Rename {
210        /// Source path the file was moved from.
211        from: PathBuf,
212        /// Stable file identity (unchanged by a rename).
213        file_id: FileId,
214        /// New blob OID if the content was also changed during the rename.
215        /// `None` means the content is identical to the epoch's blob.
216        new_blob: Option<GitOid>,
217    },
218}
219
220// ---------------------------------------------------------------------------
221// Tests
222// ---------------------------------------------------------------------------
223
224#[cfg(test)]
225#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
226mod tests {
227    use super::*;
228
229    // Helper: build a valid 40-char hex OID string.
230    fn oid(c: char) -> String {
231        c.to_string().repeat(40)
232    }
233
234    // Helper: build a valid 40-char hex EpochId.
235    fn epoch(c: char) -> EpochId {
236        EpochId::new(&oid(c)).unwrap()
237    }
238
239    // Helper: build a valid GitOid.
240    fn git_oid(c: char) -> GitOid {
241        GitOid::new(&oid(c)).unwrap()
242    }
243
244    // -----------------------------------------------------------------------
245    // FileId tests
246    // -----------------------------------------------------------------------
247
248    #[test]
249    fn file_id_round_trip_u128() {
250        let id = FileId::new(42);
251        assert_eq!(id.as_u128(), 42);
252    }
253
254    #[test]
255    fn file_id_display_is_32_hex_chars() {
256        let id = FileId::new(0);
257        let s = format!("{id}");
258        assert_eq!(s.len(), 32);
259        assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
260    }
261
262    #[test]
263    fn file_id_to_hex_round_trip() {
264        for n in [0_u128, 1, u128::from(u64::MAX), u128::MAX] {
265            let id = FileId::new(n);
266            let hex = id.to_hex();
267            let decoded = FileId::from_hex(&hex).unwrap();
268            assert_eq!(decoded, id);
269        }
270    }
271
272    #[test]
273    fn file_id_from_hex_rejects_short() {
274        assert!(FileId::from_hex("abc").is_err());
275    }
276
277    #[test]
278    fn file_id_from_hex_rejects_long() {
279        assert!(FileId::from_hex(&"a".repeat(33)).is_err());
280    }
281
282    #[test]
283    fn file_id_from_hex_rejects_uppercase() {
284        let hex = "A".repeat(32);
285        assert!(FileId::from_hex(&hex).is_err());
286    }
287
288    #[test]
289    fn file_id_from_hex_rejects_non_hex() {
290        let bad = "z".repeat(32);
291        assert!(FileId::from_hex(&bad).is_err());
292    }
293
294    #[test]
295    fn file_id_serde_round_trip() {
296        let id = FileId::new(0xdead_beef_cafe);
297        let json = serde_json::to_string(&id).unwrap();
298        // Serialized as quoted hex string.
299        assert!(json.starts_with('"'));
300        let decoded: FileId = serde_json::from_str(&json).unwrap();
301        assert_eq!(decoded, id);
302    }
303
304    #[test]
305    fn file_id_serde_rejects_invalid() {
306        let json = "\"not-a-valid-id\"";
307        assert!(serde_json::from_str::<FileId>(json).is_err());
308    }
309
310    #[test]
311    fn file_id_zero_display() {
312        assert_eq!(FileId::new(0).to_hex(), "0".repeat(32));
313    }
314
315    #[test]
316    fn file_id_max_display() {
317        assert_eq!(FileId::new(u128::MAX).to_hex(), "f".repeat(32));
318    }
319
320    // -----------------------------------------------------------------------
321    // PatchSet tests
322    // -----------------------------------------------------------------------
323
324    #[test]
325    fn patch_set_empty() {
326        let ps = PatchSet::empty(epoch('1'));
327        assert!(ps.is_empty());
328        assert_eq!(ps.len(), 0);
329    }
330
331    #[test]
332    fn patch_set_len_and_is_empty() {
333        let mut ps = PatchSet::empty(epoch('2'));
334        ps.patches.insert(
335            PathBuf::from("src/main.rs"),
336            PatchValue::Add {
337                blob: git_oid('a'),
338                file_id: FileId::new(1),
339            },
340        );
341        assert!(!ps.is_empty());
342        assert_eq!(ps.len(), 1);
343    }
344
345    #[test]
346    fn patch_set_btreemap_is_sorted() {
347        let mut ps = PatchSet::empty(epoch('3'));
348        // Insert in reverse order.
349        ps.patches.insert(
350            PathBuf::from("z.rs"),
351            PatchValue::Add {
352                blob: git_oid('a'),
353                file_id: FileId::new(10),
354            },
355        );
356        ps.patches.insert(
357            PathBuf::from("a.rs"),
358            PatchValue::Add {
359                blob: git_oid('b'),
360                file_id: FileId::new(11),
361            },
362        );
363
364        // BTreeMap iteration is always sorted.
365        let keys: Vec<_> = ps.patches.keys().collect();
366        assert_eq!(keys[0], &PathBuf::from("a.rs"));
367        assert_eq!(keys[1], &PathBuf::from("z.rs"));
368    }
369
370    #[test]
371    fn patch_set_serde_round_trip_empty() {
372        let ps = PatchSet::empty(epoch('4'));
373        let json = serde_json::to_string(&ps).unwrap();
374        let decoded: PatchSet = serde_json::from_str(&json).unwrap();
375        assert_eq!(decoded, ps);
376    }
377
378    #[test]
379    fn patch_set_serde_round_trip_with_entries() {
380        let mut ps = PatchSet::empty(epoch('5'));
381        ps.patches.insert(
382            PathBuf::from("src/lib.rs"),
383            PatchValue::Modify {
384                base_blob: git_oid('b'),
385                new_blob: git_oid('c'),
386                file_id: FileId::new(99),
387            },
388        );
389        ps.patches.insert(
390            PathBuf::from("README.md"),
391            PatchValue::Delete {
392                previous_blob: git_oid('d'),
393                file_id: FileId::new(100),
394            },
395        );
396
397        let json = serde_json::to_string(&ps).unwrap();
398        let decoded: PatchSet = serde_json::from_str(&json).unwrap();
399        assert_eq!(decoded, ps);
400    }
401
402    // -----------------------------------------------------------------------
403    // PatchValue tests — construction + serde round-trip for each variant
404    // -----------------------------------------------------------------------
405
406    #[test]
407    fn patch_value_add_round_trip() {
408        let pv = PatchValue::Add {
409            blob: git_oid('a'),
410            file_id: FileId::new(1),
411        };
412        let json = serde_json::to_string(&pv).unwrap();
413        // Tagged with "op":"add"
414        assert!(json.contains("\"op\":\"add\""));
415        let decoded: PatchValue = serde_json::from_str(&json).unwrap();
416        assert_eq!(decoded, pv);
417    }
418
419    #[test]
420    fn patch_value_delete_round_trip() {
421        let pv = PatchValue::Delete {
422            previous_blob: git_oid('b'),
423            file_id: FileId::new(2),
424        };
425        let json = serde_json::to_string(&pv).unwrap();
426        assert!(json.contains("\"op\":\"delete\""));
427        let decoded: PatchValue = serde_json::from_str(&json).unwrap();
428        assert_eq!(decoded, pv);
429    }
430
431    #[test]
432    fn patch_value_modify_round_trip() {
433        let pv = PatchValue::Modify {
434            base_blob: git_oid('c'),
435            new_blob: git_oid('d'),
436            file_id: FileId::new(3),
437        };
438        let json = serde_json::to_string(&pv).unwrap();
439        assert!(json.contains("\"op\":\"modify\""));
440        let decoded: PatchValue = serde_json::from_str(&json).unwrap();
441        assert_eq!(decoded, pv);
442    }
443
444    #[test]
445    fn patch_value_rename_no_content_change_round_trip() {
446        let pv = PatchValue::Rename {
447            from: PathBuf::from("old/path.rs"),
448            file_id: FileId::new(4),
449            new_blob: None,
450        };
451        let json = serde_json::to_string(&pv).unwrap();
452        assert!(json.contains("\"op\":\"rename\""));
453        assert!(json.contains("\"new_blob\":null"));
454        let decoded: PatchValue = serde_json::from_str(&json).unwrap();
455        assert_eq!(decoded, pv);
456    }
457
458    #[test]
459    fn patch_value_rename_with_content_change_round_trip() {
460        let pv = PatchValue::Rename {
461            from: PathBuf::from("old/path.rs"),
462            file_id: FileId::new(5),
463            new_blob: Some(git_oid('e')),
464        };
465        let json = serde_json::to_string(&pv).unwrap();
466        assert!(json.contains("\"op\":\"rename\""));
467        let decoded: PatchValue = serde_json::from_str(&json).unwrap();
468        assert_eq!(decoded, pv);
469    }
470
471    #[test]
472    fn patch_value_serde_tagged() {
473        // Confirm the "op" tag is present in all variants.
474        let variants: &[PatchValue] = &[
475            PatchValue::Add {
476                blob: git_oid('a'),
477                file_id: FileId::new(10),
478            },
479            PatchValue::Delete {
480                previous_blob: git_oid('b'),
481                file_id: FileId::new(11),
482            },
483            PatchValue::Modify {
484                base_blob: git_oid('c'),
485                new_blob: git_oid('d'),
486                file_id: FileId::new(12),
487            },
488            PatchValue::Rename {
489                from: PathBuf::from("foo.rs"),
490                file_id: FileId::new(13),
491                new_blob: None,
492            },
493        ];
494
495        for pv in variants {
496            let json = serde_json::to_string(pv).unwrap();
497            assert!(json.contains("\"op\":"), "Missing 'op' tag in: {json}");
498            let decoded: PatchValue = serde_json::from_str(&json).unwrap();
499            assert_eq!(&decoded, pv);
500        }
501    }
502
503    #[test]
504    fn patch_set_json_is_deterministic() {
505        // Two PatchSets with the same content should serialize identically.
506        let make = || {
507            let mut ps = PatchSet::empty(epoch('6'));
508            ps.patches.insert(
509                PathBuf::from("b.rs"),
510                PatchValue::Add {
511                    blob: git_oid('1'),
512                    file_id: FileId::new(20),
513                },
514            );
515            ps.patches.insert(
516                PathBuf::from("a.rs"),
517                PatchValue::Add {
518                    blob: git_oid('2'),
519                    file_id: FileId::new(21),
520                },
521            );
522            ps
523        };
524        let json1 = serde_json::to_string(&make()).unwrap();
525        let json2 = serde_json::to_string(&make()).unwrap();
526        assert_eq!(json1, json2);
527    }
528}