Skip to main content

maw/model/
file_id.rs

1//! `FileId` ↔ path mapping — persistent sidecar for stable file identity (§5.8).
2//!
3//! # Overview
4//!
5//! [`FileId`] is a 128-bit random identifier assigned to a file when it is
6//! first created. It never changes, even if the file is renamed, moved, or
7//! has its content modified. This makes rename-aware merge possible without
8//! heuristics.
9//!
10//! This module provides [`FileIdMap`]: a bidirectional index between stable
11//! file identities and their current paths. The map can be persisted to
12//! `.manifold/fileids` (JSON) and loaded back.
13//!
14//! # Operations
15//!
16//! | Operation | `FileId` | Path |
17//! |-----------|--------|------|
18//! | Create    | new (random) | new path |
19//! | Rename    | unchanged | old → new path |
20//! | Modify    | unchanged | unchanged path |
21//! | Copy      | new (random) | new path |
22//! | Delete    | removed | removed |
23//!
24//! # Concurrent rename + edit (§5.8)
25//!
26//! Workspace A renames `foo.rs → bar.rs` (same `FileId`, different path key).
27//! Workspace B modifies `foo.rs` (same `FileId`, same path key, different blob).
28//!
29//! During patch-set join, both patches carry the same `FileId`. The merge engine
30//! sees:
31//! - One workspace changed the path (Rename).
32//! - The other changed the content (Modify).
33//!
34//! Result: `bar.rs` with B's content. No heuristics needed.
35//!
36//! # File format
37//!
38//! `.manifold/fileids` is a JSON file containing an array of `{"path": "...",
39//! "file_id": "..."}` records (one per tracked file, sorted by path for
40//! deterministic diffs):
41//!
42//! ```json
43//! [
44//!   {"path": "src/lib.rs", "file_id": "0000...0001"},
45//!   {"path": "src/main.rs", "file_id": "0000...0002"}
46//! ]
47//! ```
48
49use std::collections::BTreeMap;
50use std::fmt;
51use std::fs;
52use std::io;
53use std::path::{Path, PathBuf};
54
55use serde::{Deserialize, Serialize};
56
57use super::patch::FileId;
58
59// ---------------------------------------------------------------------------
60// FileIdMap
61// ---------------------------------------------------------------------------
62
63/// Bidirectional mapping between [`FileId`] and the current path of each
64/// tracked file (§5.8).
65///
66/// Invariants maintained by all mutating methods:
67/// - Every `FileId` maps to exactly one path.
68/// - Every path maps to exactly one `FileId`.
69/// - The two maps are always consistent with each other.
70///
71/// These invariants guarantee O(1) lookup in both directions.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct FileIdMap {
74    /// Canonical index: path → `FileId` (serialized).
75    path_to_id: BTreeMap<PathBuf, FileId>,
76    /// Reverse index: `FileId` → path (rebuilt from `path_to_id` on load).
77    id_to_path: BTreeMap<FileId, PathBuf>,
78}
79
80impl Default for FileIdMap {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl FileIdMap {
87    /// Create an empty map.
88    #[must_use]
89    pub const fn new() -> Self {
90        Self {
91            path_to_id: BTreeMap::new(),
92            id_to_path: BTreeMap::new(),
93        }
94    }
95
96    // -----------------------------------------------------------------------
97    // Mutation — file lifecycle
98    // -----------------------------------------------------------------------
99
100    /// Track a newly created file.
101    ///
102    /// Assigns a fresh random [`FileId`] to `path` and returns it.
103    ///
104    /// # Errors
105    /// Returns [`FileIdMapError::PathAlreadyTracked`] if `path` is already
106    /// tracked. A file must be deleted before it can be re-created at the
107    /// same path.
108    pub fn track_new(&mut self, path: PathBuf) -> Result<FileId, FileIdMapError> {
109        if self.path_to_id.contains_key(&path) {
110            return Err(FileIdMapError::PathAlreadyTracked(path));
111        }
112        let id = FileId::random();
113        self.path_to_id.insert(path.clone(), id);
114        self.id_to_path.insert(id, path);
115        Ok(id)
116    }
117
118    /// Track a file rename: `old_path` → `new_path`.
119    ///
120    /// The [`FileId`] is preserved — only the path mapping changes.
121    ///
122    /// # Errors
123    /// - [`FileIdMapError::PathNotTracked`] if `old_path` is not tracked.
124    /// - [`FileIdMapError::PathAlreadyTracked`] if `new_path` is already
125    ///   tracked by a different file.
126    pub fn track_rename(
127        &mut self,
128        old_path: &Path,
129        new_path: PathBuf,
130    ) -> Result<FileId, FileIdMapError> {
131        let id = self
132            .path_to_id
133            .remove(old_path)
134            .ok_or_else(|| FileIdMapError::PathNotTracked(old_path.to_path_buf()))?;
135
136        // Check destination is free.
137        if let Some(&existing_id) = self.path_to_id.get(&new_path)
138            && existing_id != id
139        {
140            // Restore old mapping before returning the error.
141            self.path_to_id.insert(old_path.to_path_buf(), id);
142            return Err(FileIdMapError::PathAlreadyTracked(new_path));
143        }
144
145        self.id_to_path.insert(id, new_path.clone());
146        self.path_to_id.insert(new_path, id);
147        Ok(id)
148    }
149
150    /// Track a file copy: create `dst_path` as a copy of `src_path`.
151    ///
152    /// Assigns a **new** random [`FileId`] to the copy. The source file
153    /// keeps its original identity. This is explicit, not inferred from
154    /// content similarity.
155    ///
156    /// # Errors
157    /// - [`FileIdMapError::PathNotTracked`] if `src_path` is not tracked.
158    /// - [`FileIdMapError::PathAlreadyTracked`] if `dst_path` is already
159    ///   tracked.
160    pub fn track_copy(
161        &mut self,
162        src_path: &Path,
163        dst_path: PathBuf,
164    ) -> Result<FileId, FileIdMapError> {
165        if !self.path_to_id.contains_key(src_path) {
166            return Err(FileIdMapError::PathNotTracked(src_path.to_path_buf()));
167        }
168        if self.path_to_id.contains_key(&dst_path) {
169            return Err(FileIdMapError::PathAlreadyTracked(dst_path));
170        }
171        // New FileId for the copy — explicit, not inherited.
172        let new_id = FileId::random();
173        self.path_to_id.insert(dst_path.clone(), new_id);
174        self.id_to_path.insert(new_id, dst_path);
175        Ok(new_id)
176    }
177
178    /// Track a file deletion.
179    ///
180    /// Removes both mappings. Returns the [`FileId`] that was assigned to
181    /// the deleted file.
182    ///
183    /// # Errors
184    /// Returns [`FileIdMapError::PathNotTracked`] if `path` is not tracked.
185    pub fn track_delete(&mut self, path: &Path) -> Result<FileId, FileIdMapError> {
186        let id = self
187            .path_to_id
188            .remove(path)
189            .ok_or_else(|| FileIdMapError::PathNotTracked(path.to_path_buf()))?;
190        self.id_to_path.remove(&id);
191        Ok(id)
192    }
193
194    // -----------------------------------------------------------------------
195    // Lookup
196    // -----------------------------------------------------------------------
197
198    /// Look up the [`FileId`] for a given path. Returns `None` if untracked.
199    #[must_use]
200    pub fn id_for_path(&self, path: &Path) -> Option<FileId> {
201        self.path_to_id.get(path).copied()
202    }
203
204    /// Look up the current path for a given [`FileId`]. Returns `None` if
205    /// not tracked (file was deleted or never registered).
206    #[must_use]
207    pub fn path_for_id(&self, id: FileId) -> Option<&Path> {
208        self.id_to_path.get(&id).map(PathBuf::as_path)
209    }
210
211    /// Return `true` if `path` is currently tracked.
212    #[must_use]
213    pub fn contains_path(&self, path: &Path) -> bool {
214        self.path_to_id.contains_key(path)
215    }
216
217    /// Return `true` if `id` is currently tracked.
218    #[must_use]
219    pub fn contains_id(&self, id: FileId) -> bool {
220        self.id_to_path.contains_key(&id)
221    }
222
223    /// Return the number of tracked files.
224    #[must_use]
225    pub fn len(&self) -> usize {
226        self.path_to_id.len()
227    }
228
229    /// Return `true` if no files are tracked.
230    #[must_use]
231    pub fn is_empty(&self) -> bool {
232        self.path_to_id.is_empty()
233    }
234
235    /// Iterate over all `(path, FileId)` entries in sorted path order.
236    pub fn iter(&self) -> impl Iterator<Item = (&Path, FileId)> {
237        self.path_to_id.iter().map(|(p, &id)| (p.as_path(), id))
238    }
239
240    // -----------------------------------------------------------------------
241    // Persistence
242    // -----------------------------------------------------------------------
243
244    /// Load the map from a `.manifold/fileids` JSON file.
245    ///
246    /// Returns an empty map if the file does not exist (first run).
247    ///
248    /// # Errors
249    /// Returns [`FileIdMapError::Io`] on I/O failure (other than not-found),
250    /// or [`FileIdMapError::Json`] on JSON parse failure.
251    pub fn load(path: &Path) -> Result<Self, FileIdMapError> {
252        match fs::read_to_string(path) {
253            Ok(content) => {
254                let records: Vec<FileIdRecord> =
255                    serde_json::from_str(&content).map_err(FileIdMapError::Json)?;
256                Self::from_records(records)
257            }
258            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::new()),
259            Err(e) => Err(FileIdMapError::Io(e)),
260        }
261    }
262
263    /// Save the map to a `.manifold/fileids` JSON file.
264    ///
265    /// Writes are atomic: content is first written to `<path>.tmp` then
266    /// renamed over the destination. This prevents a crash mid-write from
267    /// leaving a corrupt file.
268    ///
269    /// Parent directories are created if they don't exist.
270    ///
271    /// # Errors
272    /// Returns [`FileIdMapError::Io`] on I/O failure, or
273    /// [`FileIdMapError::Json`] on serialization failure.
274    pub fn save(&self, path: &Path) -> Result<(), FileIdMapError> {
275        if let Some(parent) = path.parent() {
276            fs::create_dir_all(parent).map_err(FileIdMapError::Io)?;
277        }
278
279        let records: Vec<FileIdRecord> = self
280            .path_to_id
281            .iter()
282            .map(|(p, &id)| FileIdRecord {
283                path: p.clone(),
284                file_id: id,
285            })
286            .collect();
287
288        let json = serde_json::to_string_pretty(&records).map_err(FileIdMapError::Json)?;
289
290        // Atomic write: write to tmp, then rename.
291        let tmp_path = path.with_extension("tmp");
292        fs::write(&tmp_path, json).map_err(FileIdMapError::Io)?;
293        fs::rename(&tmp_path, path).map_err(FileIdMapError::Io)?;
294
295        Ok(())
296    }
297
298    // -----------------------------------------------------------------------
299    // Internal helpers
300    // -----------------------------------------------------------------------
301
302    /// Rebuild the map from serialized records, validating consistency.
303    fn from_records(records: Vec<FileIdRecord>) -> Result<Self, FileIdMapError> {
304        let mut map = Self::new();
305        for record in records {
306            // Detect duplicate paths (shouldn't happen in a well-formed file).
307            if map.path_to_id.contains_key(&record.path) {
308                return Err(FileIdMapError::DuplicatePath(record.path));
309            }
310            // Detect duplicate FileIds (shouldn't happen in a well-formed file).
311            if map.id_to_path.contains_key(&record.file_id) {
312                return Err(FileIdMapError::DuplicateFileId(record.file_id));
313            }
314            map.id_to_path.insert(record.file_id, record.path.clone());
315            map.path_to_id.insert(record.path, record.file_id);
316        }
317        Ok(map)
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Serialization record
323// ---------------------------------------------------------------------------
324
325/// A single entry in the `.manifold/fileids` JSON file.
326#[derive(Serialize, Deserialize, Debug, Clone)]
327struct FileIdRecord {
328    path: PathBuf,
329    file_id: FileId,
330}
331
332// ---------------------------------------------------------------------------
333// FileIdMapError
334// ---------------------------------------------------------------------------
335
336/// Errors produced by [`FileIdMap`] operations.
337#[derive(Debug)]
338pub enum FileIdMapError {
339    /// The specified path is already tracked by a file with a different `FileId`.
340    PathAlreadyTracked(PathBuf),
341    /// The specified path is not tracked in the map.
342    PathNotTracked(PathBuf),
343    /// Two records in the persisted file share the same path.
344    DuplicatePath(PathBuf),
345    /// Two records in the persisted file share the same `FileId`.
346    DuplicateFileId(FileId),
347    /// I/O error reading or writing the fileids file.
348    Io(io::Error),
349    /// JSON (de)serialization error.
350    Json(serde_json::Error),
351}
352
353impl fmt::Display for FileIdMapError {
354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
355        match self {
356            Self::PathAlreadyTracked(p) => {
357                write!(f, "path already tracked: {}", p.display())
358            }
359            Self::PathNotTracked(p) => {
360                write!(f, "path not tracked: {}", p.display())
361            }
362            Self::DuplicatePath(p) => {
363                write!(f, "corrupt fileids: duplicate path entry: {}", p.display())
364            }
365            Self::DuplicateFileId(id) => {
366                write!(f, "corrupt fileids: duplicate FileId: {id}")
367            }
368            Self::Io(e) => write!(f, "I/O error: {e}"),
369            Self::Json(e) => write!(f, "JSON error: {e}"),
370        }
371    }
372}
373
374impl std::error::Error for FileIdMapError {
375    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
376        match self {
377            Self::Io(e) => Some(e),
378            Self::Json(e) => Some(e),
379            _ => None,
380        }
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Tests
386// ---------------------------------------------------------------------------
387
388#[cfg(test)]
389#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
390mod tests {
391    use super::*;
392
393    // -----------------------------------------------------------------------
394    // Basic operations
395    // -----------------------------------------------------------------------
396
397    #[test]
398    fn track_new_assigns_fresh_id() {
399        let mut map = FileIdMap::new();
400        let id = map.track_new("src/main.rs".into()).unwrap();
401        assert!(map.contains_path(Path::new("src/main.rs")));
402        assert!(map.contains_id(id));
403        assert_eq!(map.id_for_path(Path::new("src/main.rs")), Some(id));
404        assert_eq!(map.path_for_id(id), Some(Path::new("src/main.rs")));
405        assert_eq!(map.len(), 1);
406    }
407
408    #[test]
409    fn track_new_generates_unique_ids() {
410        let mut map = FileIdMap::new();
411        let id_a = map.track_new("a.rs".into()).unwrap();
412        let id_b = map.track_new("b.rs".into()).unwrap();
413        // IDs must be distinct (with overwhelming probability).
414        assert_ne!(id_a, id_b);
415    }
416
417    #[test]
418    fn track_new_rejects_duplicate_path() {
419        let mut map = FileIdMap::new();
420        map.track_new("src/lib.rs".into()).unwrap();
421        let err = map.track_new("src/lib.rs".into()).unwrap_err();
422        assert!(matches!(err, FileIdMapError::PathAlreadyTracked(_)));
423        assert_eq!(map.len(), 1); // Map unchanged.
424    }
425
426    #[test]
427    fn track_rename_preserves_file_id() {
428        let mut map = FileIdMap::new();
429        let id = map.track_new("foo.rs".into()).unwrap();
430
431        let returned_id = map
432            .track_rename(Path::new("foo.rs"), "bar.rs".into())
433            .unwrap();
434
435        assert_eq!(returned_id, id, "FileId must be unchanged by rename");
436        assert!(!map.contains_path(Path::new("foo.rs")));
437        assert!(map.contains_path(Path::new("bar.rs")));
438        assert_eq!(map.id_for_path(Path::new("bar.rs")), Some(id));
439        assert_eq!(map.path_for_id(id), Some(Path::new("bar.rs")));
440        assert_eq!(map.len(), 1);
441    }
442
443    #[test]
444    fn track_rename_rejects_unknown_source() {
445        let mut map = FileIdMap::new();
446        let err = map
447            .track_rename(Path::new("missing.rs"), "dest.rs".into())
448            .unwrap_err();
449        assert!(matches!(err, FileIdMapError::PathNotTracked(_)));
450    }
451
452    #[test]
453    fn track_rename_rejects_occupied_destination() {
454        let mut map = FileIdMap::new();
455        map.track_new("a.rs".into()).unwrap();
456        map.track_new("b.rs".into()).unwrap();
457
458        let err = map
459            .track_rename(Path::new("a.rs"), "b.rs".into())
460            .unwrap_err();
461        assert!(matches!(err, FileIdMapError::PathAlreadyTracked(_)));
462        // Both originals must still be intact.
463        assert_eq!(map.len(), 2);
464        assert!(map.contains_path(Path::new("a.rs")));
465        assert!(map.contains_path(Path::new("b.rs")));
466    }
467
468    #[test]
469    fn track_copy_assigns_new_id() {
470        let mut map = FileIdMap::new();
471        let src_id = map.track_new("src/lib.rs".into()).unwrap();
472
473        let dst_id = map
474            .track_copy(Path::new("src/lib.rs"), "src/lib_copy.rs".into())
475            .unwrap();
476
477        assert_ne!(dst_id, src_id, "copy gets a new FileId");
478        assert_eq!(map.len(), 2);
479        assert_eq!(map.id_for_path(Path::new("src/lib.rs")), Some(src_id));
480        assert_eq!(map.id_for_path(Path::new("src/lib_copy.rs")), Some(dst_id));
481    }
482
483    #[test]
484    fn track_copy_rejects_unknown_source() {
485        let mut map = FileIdMap::new();
486        let err = map
487            .track_copy(Path::new("missing.rs"), "dest.rs".into())
488            .unwrap_err();
489        assert!(matches!(err, FileIdMapError::PathNotTracked(_)));
490    }
491
492    #[test]
493    fn track_copy_rejects_occupied_destination() {
494        let mut map = FileIdMap::new();
495        map.track_new("a.rs".into()).unwrap();
496        map.track_new("b.rs".into()).unwrap();
497
498        let err = map
499            .track_copy(Path::new("a.rs"), "b.rs".into())
500            .unwrap_err();
501        assert!(matches!(err, FileIdMapError::PathAlreadyTracked(_)));
502        assert_eq!(map.len(), 2);
503    }
504
505    #[test]
506    fn track_delete_removes_both_mappings() {
507        let mut map = FileIdMap::new();
508        let id = map.track_new("gone.rs".into()).unwrap();
509
510        let returned = map.track_delete(Path::new("gone.rs")).unwrap();
511
512        assert_eq!(returned, id);
513        assert!(!map.contains_path(Path::new("gone.rs")));
514        assert!(!map.contains_id(id));
515        assert_eq!(map.len(), 0);
516    }
517
518    #[test]
519    fn track_delete_rejects_unknown_path() {
520        let mut map = FileIdMap::new();
521        let err = map.track_delete(Path::new("nope.rs")).unwrap_err();
522        assert!(matches!(err, FileIdMapError::PathNotTracked(_)));
523    }
524
525    #[test]
526    fn track_new_after_delete_same_path() {
527        let mut map = FileIdMap::new();
528        let id1 = map.track_new("file.rs".into()).unwrap();
529        map.track_delete(Path::new("file.rs")).unwrap();
530        let id2 = map.track_new("file.rs".into()).unwrap();
531
532        // New file at same path gets a brand-new FileId.
533        assert_ne!(id1, id2);
534        assert_eq!(map.len(), 1);
535    }
536
537    // -----------------------------------------------------------------------
538    // Consistency invariants
539    // -----------------------------------------------------------------------
540
541    #[test]
542    fn map_len_matches_both_directions() {
543        let mut map = FileIdMap::new();
544        assert_eq!(map.path_to_id.len(), map.id_to_path.len());
545
546        map.track_new("a.rs".into()).unwrap();
547        assert_eq!(map.path_to_id.len(), map.id_to_path.len());
548
549        map.track_new("b.rs".into()).unwrap();
550        assert_eq!(map.path_to_id.len(), map.id_to_path.len());
551
552        map.track_rename(Path::new("a.rs"), "c.rs".into()).unwrap();
553        assert_eq!(map.path_to_id.len(), map.id_to_path.len());
554
555        map.track_delete(Path::new("b.rs")).unwrap();
556        assert_eq!(map.path_to_id.len(), map.id_to_path.len());
557    }
558
559    #[test]
560    fn iter_returns_sorted_paths() {
561        let mut map = FileIdMap::new();
562        map.track_new("z.rs".into()).unwrap();
563        map.track_new("a.rs".into()).unwrap();
564        map.track_new("m.rs".into()).unwrap();
565
566        let paths: Vec<_> = map.iter().map(|(p, _)| p.to_path_buf()).collect();
567        let mut sorted = paths.clone();
568        sorted.sort();
569        assert_eq!(paths, sorted, "iter must return paths in sorted order");
570    }
571
572    // -----------------------------------------------------------------------
573    // Persistence: save + load round-trip
574    // -----------------------------------------------------------------------
575
576    #[test]
577    fn save_and_load_round_trip() {
578        let dir = tempfile::tempdir().unwrap();
579        let fileids_path = dir.path().join(".manifold").join("fileids");
580
581        let mut map = FileIdMap::new();
582        let id_a = map.track_new("src/main.rs".into()).unwrap();
583        let id_b = map.track_new("src/lib.rs".into()).unwrap();
584        map.save(&fileids_path).unwrap();
585
586        let loaded = FileIdMap::load(&fileids_path).unwrap();
587        assert_eq!(loaded.len(), 2);
588        assert_eq!(loaded.id_for_path(Path::new("src/main.rs")), Some(id_a));
589        assert_eq!(loaded.id_for_path(Path::new("src/lib.rs")), Some(id_b));
590        assert_eq!(loaded.path_for_id(id_a), Some(Path::new("src/main.rs")));
591        assert_eq!(loaded.path_for_id(id_b), Some(Path::new("src/lib.rs")));
592    }
593
594    #[test]
595    fn load_missing_file_returns_empty_map() {
596        let dir = tempfile::tempdir().unwrap();
597        let fileids_path = dir.path().join(".manifold").join("fileids");
598
599        let map = FileIdMap::load(&fileids_path).unwrap();
600        assert!(map.is_empty());
601    }
602
603    #[test]
604    fn save_creates_parent_directories() {
605        let dir = tempfile::tempdir().unwrap();
606        let nested = dir
607            .path()
608            .join("deep")
609            .join("nested")
610            .join(".manifold")
611            .join("fileids");
612
613        let map = FileIdMap::new();
614        map.save(&nested).unwrap();
615        assert!(nested.exists());
616    }
617
618    #[test]
619    fn save_produces_valid_json() {
620        let dir = tempfile::tempdir().unwrap();
621        let fileids_path = dir.path().join("fileids");
622
623        let mut map = FileIdMap::new();
624        map.track_new("src/main.rs".into()).unwrap();
625        map.save(&fileids_path).unwrap();
626
627        let content = fs::read_to_string(&fileids_path).unwrap();
628        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
629        assert!(parsed.is_array());
630        let arr = parsed.as_array().unwrap();
631        assert_eq!(arr.len(), 1);
632        assert!(arr[0].get("path").is_some());
633        assert!(arr[0].get("file_id").is_some());
634    }
635
636    #[test]
637    fn save_is_deterministic() {
638        // Two maps with the same contents produce identical JSON.
639        let make = || {
640            let dir = tempfile::tempdir().unwrap();
641            let p = dir.path().join("fileids");
642            let mut map = FileIdMap::new();
643            // Use fixed FileIds via direct insertion (bypass random).
644            let id1 = FileId::new(0x1111_1111_1111_1111_1111_1111_1111_1111);
645            let id2 = FileId::new(0x2222_2222_2222_2222_2222_2222_2222_2222);
646            map.path_to_id.insert("a.rs".into(), id1);
647            map.id_to_path.insert(id1, "a.rs".into());
648            map.path_to_id.insert("b.rs".into(), id2);
649            map.id_to_path.insert(id2, "b.rs".into());
650            map.save(&p).unwrap();
651            (dir, p)
652        };
653        let (_dir1, p1) = make();
654        let (_dir2, p2) = make();
655        let c1 = fs::read_to_string(&p1).unwrap();
656        let c2 = fs::read_to_string(&p2).unwrap();
657        assert_eq!(c1, c2, "save must be deterministic");
658    }
659
660    #[test]
661    fn load_detects_duplicate_paths() {
662        let dir = tempfile::tempdir().unwrap();
663        let p = dir.path().join("fileids");
664        // Write a corrupt file with duplicate paths.
665        let json = r#"[
666            {"path": "foo.rs", "file_id": "00000000000000000000000000000001"},
667            {"path": "foo.rs", "file_id": "00000000000000000000000000000002"}
668        ]"#;
669        fs::write(&p, json).unwrap();
670        let err = FileIdMap::load(&p).unwrap_err();
671        assert!(matches!(err, FileIdMapError::DuplicatePath(_)));
672    }
673
674    #[test]
675    fn load_detects_duplicate_file_ids() {
676        let dir = tempfile::tempdir().unwrap();
677        let p = dir.path().join("fileids");
678        // Write a corrupt file with duplicate FileIds.
679        let json = r#"[
680            {"path": "a.rs", "file_id": "00000000000000000000000000000001"},
681            {"path": "b.rs", "file_id": "00000000000000000000000000000001"}
682        ]"#;
683        fs::write(&p, json).unwrap();
684        let err = FileIdMap::load(&p).unwrap_err();
685        assert!(matches!(err, FileIdMapError::DuplicateFileId(_)));
686    }
687
688    // -----------------------------------------------------------------------
689    // §5.8 scenario: concurrent rename + edit
690    // -----------------------------------------------------------------------
691
692    /// Verifies the design doc's core claim (§5.8):
693    ///
694    /// "If workspace A renames foo.rs → bar.rs and workspace B modifies
695    /// foo.rs, Manifold sees: same `FileId`, one workspace changed the path,
696    /// one changed the content. Clean merge to bar.rs with B's edits.
697    /// Without `FileId`, this is a delete+add+modify mess."
698    ///
699    /// This test demonstrates that:
700    /// 1. Both patch-sets carry the same `FileId`.
701    /// 2. The `FileIdMap` confirms the rename.
702    /// 3. A merge engine can identify the correct resolution.
703    #[test]
704    fn concurrent_rename_and_edit_same_file_id() {
705        // Common base state: foo.rs exists with a known FileId.
706        let mut base_map = FileIdMap::new();
707        let foo_id = base_map.track_new("foo.rs".into()).unwrap();
708
709        // --- Workspace A: renames foo.rs → bar.rs ---
710        let mut map_a = base_map.clone();
711        let rename_id = map_a
712            .track_rename(Path::new("foo.rs"), "bar.rs".into())
713            .unwrap();
714        assert_eq!(rename_id, foo_id, "FileId unchanged across rename");
715        assert!(!map_a.contains_path(Path::new("foo.rs")));
716        assert!(map_a.contains_path(Path::new("bar.rs")));
717
718        // --- Workspace B: modifies foo.rs (no rename) ---
719        let map_b = base_map.clone();
720        let modify_id = map_b
721            .id_for_path(Path::new("foo.rs"))
722            .expect("foo.rs must be tracked");
723        assert_eq!(modify_id, foo_id, "FileId unchanged across modify");
724
725        // --- Merge observation ---
726        // Both workspaces agree on the FileId for the file.
727        // WS A → Rename { from: "foo.rs", file_id: foo_id, new_blob: None }
728        // WS B → Modify { base_blob: ..., new_blob: ..., file_id: foo_id }
729        //
730        // A merge engine that indexes by FileId (not path) can detect:
731        // - same FileId → same file
732        // - WS A changed path: foo.rs → bar.rs
733        // - WS B changed content
734        // - Result: bar.rs with WS B's new content
735        assert_eq!(rename_id, modify_id, "Same FileId seen in both workspaces");
736    }
737
738    /// Test that copies get a NEW `FileId`, not the source's `FileId`.
739    /// (§5.8: "Copy = new `FileId` with same initial blob. Explicit, not inferred.")
740    #[test]
741    fn copy_gets_new_file_id() {
742        let mut map = FileIdMap::new();
743        let orig_id = map.track_new("original.rs".into()).unwrap();
744        let copy_id = map
745            .track_copy(Path::new("original.rs"), "copy.rs".into())
746            .unwrap();
747
748        assert_ne!(orig_id, copy_id, "copy must have a new FileId");
749
750        // Original is unaffected.
751        assert_eq!(map.id_for_path(Path::new("original.rs")), Some(orig_id));
752    }
753
754    // -----------------------------------------------------------------------
755    // Display and error formatting
756    // -----------------------------------------------------------------------
757
758    #[test]
759    fn file_id_map_error_display_all_variants() {
760        let errors: &[FileIdMapError] = &[
761            FileIdMapError::PathAlreadyTracked("a.rs".into()),
762            FileIdMapError::PathNotTracked("b.rs".into()),
763            FileIdMapError::DuplicatePath("c.rs".into()),
764            FileIdMapError::DuplicateFileId(FileId::new(0)),
765            FileIdMapError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "oops")),
766            FileIdMapError::Json(serde_json::from_str::<FileId>("!").unwrap_err()),
767        ];
768        for err in errors {
769            let msg = format!("{err}");
770            assert!(!msg.is_empty(), "error variant must have display text");
771        }
772    }
773
774    #[test]
775    fn file_id_map_error_source() {
776        let io_err = FileIdMapError::Io(io::Error::new(io::ErrorKind::NotFound, "gone"));
777        assert!(std::error::Error::source(&io_err).is_some());
778
779        let path_err = FileIdMapError::PathNotTracked("x.rs".into());
780        assert!(std::error::Error::source(&path_err).is_none());
781    }
782
783    // -----------------------------------------------------------------------
784    // Empty map
785    // -----------------------------------------------------------------------
786
787    #[test]
788    fn empty_map_state() {
789        let map = FileIdMap::new();
790        assert!(map.is_empty());
791        assert_eq!(map.len(), 0);
792        assert_eq!(map.iter().count(), 0);
793        assert!(map.id_for_path(Path::new("any.rs")).is_none());
794        assert!(map.path_for_id(FileId::new(0)).is_none());
795    }
796
797    #[test]
798    fn default_is_empty() {
799        let map = FileIdMap::default();
800        assert!(map.is_empty());
801    }
802}