Skip to main content

mos_cache/
dependency.rs

1//! Typed build-dependency identities (manifest §7, §32; MVP 5).
2//!
3//! Incremental builds need to name *what* a cached artifact depends on before
4//! they can decide *whether* it is stale. This module supplies the vocabulary:
5//! [`DependencyKind`] is the coarse category and [`DependencyId`] is the typed,
6//! deterministic identity. Both are pure value types: there is no dirty-node
7//! invalidation, content hashing, or persistent cache here. Those later slices
8//! (see [`docs/incremental-dependencies.md`]) *consume* these identities.
9//!
10//! [`docs/incremental-dependencies.md`]: ../../../docs/incremental-dependencies.md
11//!
12//! # Scope
13//!
14//! Only inputs with a *real, stable identity today* are modelled: file-backed
15//! inputs (their canonical project path, see [`ProjectPath`]) and labels (their
16//! reference name). Categories the design note sketches but cannot yet identify
17//! deterministically: `Node` and `Style` bundles (their ids are still
18//! defaulted), packages, layout *inputs* (no real layout key until paragraph
19//! hashing lands, §4.4), and layout *outputs*: are deferred until they have a
20//! genuine identity scheme, rather than modelled as placeholders that would
21//! collide.
22//!
23//! # What is intentionally not modelled yet
24//!
25//! - **Content boundaries.** A [`DependencyId`] names a dependency; it does not
26//!   hash the bytes behind it. For bibliography inputs that pairing has landed
27//!   as [`BibliographyDependency`], which couples a
28//!   [`DependencyId::bibliography`] identity with a [`ContentHash`] boundary
29//!   (the bytes are hashed by `mos_bib::bibliography_content_hash`). Other
30//!   categories still carry identity only.
31//! - **Serialization format.** [`DependencyId`] derives [`Eq`]/[`Ord`]/[`Hash`]
32//!   so it can key in-memory maps and sets deterministically. The byte-exact
33//!   on-disk form is deferred to the persistent-cache slice; [`Display`] is a
34//!   stable, debuggable view, not the wire format.
35//!
36//! [`Display`]: core::fmt::Display
37
38use std::fmt;
39
40use mos_core::ContentHash;
41use unicode_normalization::UnicodeNormalization;
42
43/// Error returned when a path cannot be used as a project-relative dependency
44/// identity.
45#[derive(Copy, Clone, Eq, PartialEq, Debug)]
46pub enum ProjectPathError {
47    /// The path has no dependency identity after normalization.
48    Empty,
49    /// The path is absolute or carries a platform root/drive prefix.
50    Absolute,
51    /// The path climbs above the project root.
52    ParentEscape,
53}
54
55impl fmt::Display for ProjectPathError {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Empty => f.write_str("project path is empty"),
59            Self::Absolute => {
60                f.write_str("project path must be relative; make absolute paths relative first")
61            }
62            Self::ParentEscape => f.write_str("project path must not escape the project root"),
63        }
64    }
65}
66
67impl std::error::Error for ProjectPathError {}
68
69/// The category of a build dependency.
70///
71/// This is the coarse axis: "what kind of thing changed": independent of the
72/// concrete identity carried by [`DependencyId`]. Obtain it with
73/// [`DependencyId::kind`].
74///
75/// # Examples
76///
77/// ```
78/// use mos_cache::{DependencyId, DependencyKind};
79///
80/// assert_eq!(DependencyId::label("eq-euler").kind(), DependencyKind::Label);
81/// assert_eq!(DependencyKind::Bibliography.as_str(), "bibliography");
82/// ```
83#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
84pub enum DependencyKind {
85    /// A `.mos` source file.
86    SourceFile,
87    /// A referenced asset, such as an image.
88    Asset,
89    /// A bibliography input, such as a `.bib` file.
90    Bibliography,
91    /// A resolved label that references resolve against.
92    Label,
93}
94
95impl DependencyKind {
96    /// The stable lowercase tag used in [`DependencyId`]'s [`Display`] form.
97    ///
98    /// These tags are part of the debuggable identity and must stay stable.
99    ///
100    /// [`Display`]: core::fmt::Display
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use mos_cache::DependencyKind;
106    ///
107    /// assert_eq!(DependencyKind::SourceFile.as_str(), "source");
108    /// assert_eq!(DependencyKind::Label.as_str(), "label");
109    /// ```
110    #[must_use]
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::SourceFile => "source",
114            Self::Asset => "asset",
115            Self::Bibliography => "bibliography",
116            Self::Label => "label",
117        }
118    }
119}
120
121impl fmt::Display for DependencyKind {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.write_str(self.as_str())
124    }
125}
126
127/// A canonical, project-relative resource path used as a file dependency's
128/// identity.
129///
130/// The stored string *is* the identity, in canonical form:
131///
132/// - backslashes folded to `/` (so `a\b` and `a/b` agree across platforms),
133/// - `.` and empty segments dropped, `..` resolved lexically,
134/// - each segment NFC-normalized.
135///
136/// So `./a.mos`, `a.mos`, and `dir\..\dir/a.mos` all yield the same
137/// `ProjectPath`, which is exactly what makes a file [`DependencyId`]
138/// deterministic for the same logical input (design note §3.1).
139///
140/// Normalization is **lexical only**: it never touches the filesystem, so it
141/// cannot turn a relative path absolute or leak machine layout into the
142/// identity. Absolute filesystem paths are valid inputs at outer boundaries,
143/// but they must be made project-relative before becoming a `ProjectPath`.
144///
145/// # Examples
146///
147/// ```
148/// use mos_cache::ProjectPath;
149///
150/// assert_eq!(
151///     ProjectPath::new("./ch/../ch/intro.mos").map(|path| path.as_str().to_owned()),
152///     Ok("ch/intro.mos".to_owned())
153/// );
154/// assert_eq!(
155///     ProjectPath::new(r"figures\logo.png").map(|path| path.as_str().to_owned()),
156///     Ok("figures/logo.png".to_owned())
157/// );
158/// ```
159#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
160pub struct ProjectPath(String);
161
162impl ProjectPath {
163    /// Canonicalize a project-relative path into a [`ProjectPath`]. Absolute
164    /// paths must be relativized against the project root before calling this.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`ProjectPathError`] when `path` is empty, absolute, or escapes
169    /// above the project root with `..`.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use mos_cache::ProjectPath;
175    ///
176    /// // Decomposed "é" (e + combining acute) folds to the composed form.
177    /// assert_eq!(ProjectPath::new("e\u{0301}.bib"), ProjectPath::new("\u{00e9}.bib"));
178    /// ```
179    pub fn new(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
180        normalize(path.as_ref()).map(Self)
181    }
182
183    /// The canonical path string.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use mos_cache::ProjectPath;
189    ///
190    /// assert_eq!(
191    ///     ProjectPath::new("a//b/").map(|path| path.as_str().to_owned()),
192    ///     Ok("a/b".to_owned())
193    /// );
194    /// ```
195    #[must_use]
196    pub fn as_str(&self) -> &str {
197        &self.0
198    }
199}
200
201impl fmt::Display for ProjectPath {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.write_str(&self.0)
204    }
205}
206
207/// Lexically canonicalize a project-relative path: fold `\` to `/`, drop
208/// `.`/empty segments, resolve `..`, and NFC-normalize. No filesystem access.
209fn normalize(input: &str) -> Result<String, ProjectPathError> {
210    let forward = input.replace('\\', "/");
211    if forward.is_empty() {
212        return Err(ProjectPathError::Empty);
213    }
214    if forward.starts_with('/') || starts_with_windows_drive(&forward) {
215        return Err(ProjectPathError::Absolute);
216    }
217    let mut segments: Vec<&str> = Vec::new();
218    for segment in forward.split('/') {
219        match segment {
220            "" | "." => {}
221            ".." => match segments.last() {
222                // Pop a real parent segment.
223                Some(&last) if last != ".." => {
224                    segments.pop();
225                }
226                _ => return Err(ProjectPathError::ParentEscape),
227            },
228            other => segments.push(other),
229        }
230    }
231    let body: String = segments.join("/").nfc().collect();
232    if body.is_empty() {
233        return Err(ProjectPathError::Empty);
234    }
235    Ok(body)
236}
237
238fn starts_with_windows_drive(path: &str) -> bool {
239    let mut chars = path.chars();
240    matches!(
241        (chars.next(), chars.next()),
242        (Some(letter), Some(':')) if letter.is_ascii_alphabetic()
243    )
244}
245
246/// A typed, deterministic identity for one build dependency.
247///
248/// Each variant carries the payload appropriate to its [`DependencyKind`], so
249/// mismatched combinations cannot be constructed. File inputs use the canonical
250/// [`ProjectPath`]; labels use their name. The derived [`Eq`]/[`Ord`]/[`Hash`]
251/// make ids usable as keys in deterministic maps and sets; [`Display`] gives a
252/// stable `kind:payload` view for logs and debugging.
253///
254/// [`Display`]: core::fmt::Display
255///
256/// # Examples
257///
258/// ```
259/// use mos_cache::{DependencyId, DependencyKind};
260///
261/// # fn main() -> Result<(), mos_cache::ProjectPathError> {
262/// let bib = DependencyId::bibliography("./refs.bib")?;
263///
264/// assert_eq!(bib.kind(), DependencyKind::Bibliography);
265/// assert_eq!(bib.to_string(), "bibliography:refs.bib");
266/// assert_eq!(bib.path().map(|p| p.as_str()), Some("refs.bib"));
267/// # Ok(())
268/// # }
269/// ```
270#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
271pub enum DependencyId {
272    /// A `.mos` source file, identified by its canonical project path.
273    SourceFile(ProjectPath),
274    /// A referenced asset (such as an image), identified by its canonical path.
275    Asset(ProjectPath),
276    /// A bibliography input (such as a `.bib` file), by its canonical path.
277    Bibliography(ProjectPath),
278    /// A resolved label, identified by its reference name.
279    Label(String),
280}
281
282impl DependencyId {
283    /// A `.mos` source-file dependency.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`ProjectPathError`] when `path` is not a valid project-relative
288    /// path.
289    ///
290    /// # Examples
291    ///
292    /// ```
293    /// use mos_cache::DependencyId;
294    ///
295    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
296    /// assert_eq!(DependencyId::source_file("a.mos")?.to_string(), "source:a.mos");
297    /// # Ok(())
298    /// # }
299    /// ```
300    pub fn source_file(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
301        ProjectPath::new(path).map(Self::SourceFile)
302    }
303
304    /// An asset dependency, such as an image.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`ProjectPathError`] when `path` is not a valid project-relative
309    /// path.
310    ///
311    /// # Examples
312    ///
313    /// ```
314    /// use mos_cache::DependencyId;
315    ///
316    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
317    /// assert_eq!(DependencyId::asset("logo.png")?.to_string(), "asset:logo.png");
318    /// # Ok(())
319    /// # }
320    /// ```
321    pub fn asset(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
322        ProjectPath::new(path).map(Self::Asset)
323    }
324
325    /// A bibliography-input dependency, such as a `.bib` file.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`ProjectPathError`] when `path` is not a valid project-relative
330    /// path.
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use mos_cache::DependencyId;
336    ///
337    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
338    /// assert_eq!(DependencyId::bibliography("refs.bib")?.to_string(), "bibliography:refs.bib");
339    /// # Ok(())
340    /// # }
341    /// ```
342    pub fn bibliography(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
343        ProjectPath::new(path).map(Self::Bibliography)
344    }
345
346    /// A label dependency.
347    ///
348    /// # Examples
349    ///
350    /// ```
351    /// use mos_cache::DependencyId;
352    ///
353    /// assert_eq!(DependencyId::label("eq-1").to_string(), "label:eq-1");
354    /// ```
355    #[must_use]
356    pub fn label(name: impl Into<String>) -> Self {
357        Self::Label(name.into())
358    }
359
360    /// The [`DependencyKind`] this id belongs to.
361    ///
362    /// # Examples
363    ///
364    /// ```
365    /// use mos_cache::{DependencyId, DependencyKind};
366    ///
367    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
368    /// assert_eq!(DependencyId::asset("x.png")?.kind(), DependencyKind::Asset);
369    /// # Ok(())
370    /// # }
371    /// ```
372    #[must_use]
373    pub const fn kind(&self) -> DependencyKind {
374        match self {
375            Self::SourceFile(_) => DependencyKind::SourceFile,
376            Self::Asset(_) => DependencyKind::Asset,
377            Self::Bibliography(_) => DependencyKind::Bibliography,
378            Self::Label(_) => DependencyKind::Label,
379        }
380    }
381
382    /// The canonical path of a file-backed dependency, or [`None`] for labels.
383    ///
384    /// Covers the [`SourceFile`](Self::SourceFile), [`Asset`](Self::Asset), and
385    /// [`Bibliography`](Self::Bibliography) variants uniformly.
386    ///
387    /// # Examples
388    ///
389    /// ```
390    /// use mos_cache::DependencyId;
391    ///
392    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
393    /// assert_eq!(DependencyId::asset("logo.png")?.path().map(|p| p.as_str()), Some("logo.png"));
394    /// assert_eq!(DependencyId::label("eq-1").path(), None);
395    /// # Ok(())
396    /// # }
397    /// ```
398    #[must_use]
399    pub const fn path(&self) -> Option<&ProjectPath> {
400        match self {
401            Self::SourceFile(path) | Self::Asset(path) | Self::Bibliography(path) => Some(path),
402            Self::Label(_) => None,
403        }
404    }
405}
406
407impl fmt::Display for DependencyId {
408    /// Renders a stable `kind:payload` view. Equality and hashing use the exact
409    /// payloads, which this string faithfully reflects for file paths (already
410    /// canonical) and labels.
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        write!(f, "{}:", self.kind())?;
413        match self {
414            Self::SourceFile(path) | Self::Asset(path) | Self::Bibliography(path) => {
415                f.write_str(path.as_str())
416            }
417            Self::Label(name) => f.write_str(name),
418        }
419    }
420}
421
422/// A bibliography input paired with its content-hash boundary.
423///
424/// [`DependencyId::Bibliography`] answers *which* `.bib` file this is (its
425/// canonical [`ProjectPath`]); the paired [`ContentHash`] answers *what was in
426/// it* at build time. Together they are what a future incremental engine needs
427/// to decide that cached citation data is stale: the id is the cache slot, the
428/// content hash is the staleness check (design note §4.1, §7).
429///
430/// Construction guarantees the id is always the [`Bibliography`] variant, so a
431/// `BibliographyDependency` cannot be built over a source/asset/label identity
432/// by mistake: [`path`] and [`kind`] are therefore infallible.
433///
434/// The content hash is supplied by the caller rather than computed here, which
435/// keeps `mos-cache` free of any bibliography-format knowledge. Produce it from
436/// the source bytes with `mos_bib::bibliography_content_hash`; `mos-eval` (which
437/// reads the `.bib` and already depends on both crates) is the natural wiring
438/// point.
439///
440/// [`Bibliography`]: DependencyId::Bibliography
441/// [`path`]: BibliographyDependency::path
442/// [`kind`]: BibliographyDependency::kind
443///
444/// # Examples
445///
446/// ```
447/// use mos_cache::{BibliographyDependency, DependencyId, DependencyKind};
448/// use mos_core::ContentHash;
449///
450/// # fn main() -> Result<(), mos_cache::ProjectPathError> {
451/// // The content hash would come from `mos_bib::bibliography_content_hash`.
452/// let dep = BibliographyDependency::new("./refs.bib", ContentHash(0x1234))?;
453///
454/// assert_eq!(dep.kind(), DependencyKind::Bibliography);
455/// assert_eq!(dep.id(), DependencyId::bibliography("refs.bib")?);
456/// assert_eq!(dep.path().as_str(), "refs.bib");
457/// assert_eq!(dep.content(), ContentHash(0x1234));
458/// # Ok(())
459/// # }
460/// ```
461#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
462pub struct BibliographyDependency {
463    path: ProjectPath,
464    content: ContentHash,
465}
466
467impl BibliographyDependency {
468    /// Pair a bibliography source `path` with the `content` hash of its bytes.
469    ///
470    /// The path is canonicalized into a [`ProjectPath`] (§3.1), so logically
471    /// equal paths yield equal dependencies; an invalid path returns
472    /// [`ProjectPathError`].
473    ///
474    /// # Errors
475    ///
476    /// Returns [`ProjectPathError`] when `path` is not a valid project-relative
477    /// path.
478    ///
479    /// # Examples
480    ///
481    /// ```
482    /// use mos_cache::BibliographyDependency;
483    /// use mos_core::ContentHash;
484    ///
485    /// // `./ch/../refs.bib` and `refs.bib` canonicalize to one identity.
486    /// assert_eq!(
487    ///     BibliographyDependency::new("./ch/../refs.bib", ContentHash(7)),
488    ///     BibliographyDependency::new("refs.bib", ContentHash(7)),
489    /// );
490    /// ```
491    pub fn new(path: impl AsRef<str>, content: ContentHash) -> Result<Self, ProjectPathError> {
492        ProjectPath::new(path).map(|path| Self { path, content })
493    }
494
495    /// The typed dependency identity (always the [`Bibliography`] variant).
496    ///
497    /// [`Bibliography`]: DependencyId::Bibliography
498    ///
499    /// # Examples
500    ///
501    /// ```
502    /// use mos_cache::{BibliographyDependency, DependencyId};
503    /// use mos_core::ContentHash;
504    ///
505    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
506    /// let dep = BibliographyDependency::new("refs.bib", ContentHash(1))?;
507    /// assert_eq!(dep.id(), DependencyId::bibliography("refs.bib")?);
508    /// # Ok(())
509    /// # }
510    /// ```
511    #[must_use]
512    pub fn id(&self) -> DependencyId {
513        DependencyId::Bibliography(self.path.clone())
514    }
515
516    /// The canonical project path of the bibliography source.
517    ///
518    /// # Examples
519    ///
520    /// ```
521    /// use mos_cache::BibliographyDependency;
522    /// use mos_core::ContentHash;
523    ///
524    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
525    /// assert_eq!(
526    ///     BibliographyDependency::new("refs.bib", ContentHash(1))?.path().as_str(),
527    ///     "refs.bib",
528    /// );
529    /// # Ok(())
530    /// # }
531    /// ```
532    #[must_use]
533    pub const fn path(&self) -> &ProjectPath {
534        &self.path
535    }
536
537    /// The content-hash boundary of the source bytes at build time.
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// use mos_cache::BibliographyDependency;
543    /// use mos_core::ContentHash;
544    ///
545    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
546    /// assert_eq!(
547    ///     BibliographyDependency::new("refs.bib", ContentHash(42))?.content(),
548    ///     ContentHash(42),
549    /// );
550    /// # Ok(())
551    /// # }
552    /// ```
553    #[must_use]
554    pub const fn content(&self) -> ContentHash {
555        self.content
556    }
557
558    /// The [`DependencyKind`] of this dependency: always
559    /// [`Bibliography`](DependencyKind::Bibliography).
560    ///
561    /// # Examples
562    ///
563    /// ```
564    /// use mos_cache::{BibliographyDependency, DependencyKind};
565    /// use mos_core::ContentHash;
566    ///
567    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
568    /// let dep = BibliographyDependency::new("refs.bib", ContentHash(1))?;
569    /// assert_eq!(dep.kind(), DependencyKind::Bibliography);
570    /// # Ok(())
571    /// # }
572    /// ```
573    #[must_use]
574    pub const fn kind(&self) -> DependencyKind {
575        match self {
576            Self { .. } => DependencyKind::Bibliography,
577        }
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use std::collections::{BTreeSet, HashSet};
584
585    use mos_core::ContentHash;
586
587    use super::{
588        BibliographyDependency, DependencyId, DependencyKind, ProjectPath, ProjectPathError,
589    };
590
591    fn id_text(id: Result<DependencyId, ProjectPathError>) -> Result<String, ProjectPathError> {
592        id.map(|id| id.to_string())
593    }
594
595    fn id_path_text(
596        id: Result<DependencyId, ProjectPathError>,
597    ) -> Result<Option<String>, ProjectPathError> {
598        id.map(|id| id.path().map(ProjectPath::as_str).map(str::to_owned))
599    }
600
601    fn path_text(path: Result<ProjectPath, ProjectPathError>) -> Result<String, ProjectPathError> {
602        path.map(|path| path.as_str().to_owned())
603    }
604
605    #[test]
606    fn kind_matches_variant() {
607        let cases = [
608            (
609                DependencyId::source_file("a.mos").map(|id| id.kind()),
610                Ok(DependencyKind::SourceFile),
611            ),
612            (
613                DependencyId::asset("a.png").map(|id| id.kind()),
614                Ok(DependencyKind::Asset),
615            ),
616            (
617                DependencyId::bibliography("a.bib").map(|id| id.kind()),
618                Ok(DependencyKind::Bibliography),
619            ),
620            (
621                Ok(DependencyId::label("a").kind()),
622                Ok(DependencyKind::Label),
623            ),
624        ];
625        for (actual, expected) in cases {
626            assert_eq!(actual, expected);
627        }
628    }
629
630    #[test]
631    fn display_is_stable_per_kind() {
632        assert_eq!(
633            id_text(DependencyId::source_file("ch/intro.mos")),
634            Ok("source:ch/intro.mos".to_owned())
635        );
636        assert_eq!(
637            id_text(DependencyId::asset("logo.png")),
638            Ok("asset:logo.png".to_owned())
639        );
640        assert_eq!(
641            id_text(DependencyId::bibliography("refs.bib")),
642            Ok("bibliography:refs.bib".to_owned())
643        );
644        assert_eq!(
645            DependencyId::label("eq-euler").to_string(),
646            "label:eq-euler"
647        );
648    }
649
650    #[test]
651    fn path_covers_file_variants_only() {
652        assert_eq!(
653            id_path_text(DependencyId::source_file("a.mos")),
654            Ok(Some("a.mos".to_owned()))
655        );
656        assert_eq!(
657            id_path_text(DependencyId::asset("a.png")),
658            Ok(Some("a.png".to_owned()))
659        );
660        assert_eq!(
661            id_path_text(DependencyId::bibliography("a.bib")),
662            Ok(Some("a.bib".to_owned()))
663        );
664        assert_eq!(DependencyId::label("a").path(), None);
665    }
666
667    #[test]
668    fn canonical_paths_collapse_to_one_identity() {
669        let canonical = DependencyId::source_file("ch/intro.mos");
670        for variant in [
671            "./ch/intro.mos",
672            "ch/./intro.mos",
673            "ch/../ch/intro.mos",
674            r"ch\intro.mos",
675        ] {
676            assert_eq!(DependencyId::source_file(variant), canonical, "{variant}");
677        }
678    }
679
680    #[test]
681    fn nfc_variants_share_one_identity() {
682        // Decomposed vs composed "é" must hash and compare equal.
683        assert_eq!(
684            DependencyId::bibliography("e\u{0301}.bib"),
685            DependencyId::bibliography("\u{00e9}.bib")
686        );
687    }
688
689    #[test]
690    fn invalid_project_paths_are_rejected() {
691        assert_eq!(ProjectPath::new(""), Err(ProjectPathError::Empty));
692        assert_eq!(ProjectPath::new("."), Err(ProjectPathError::Empty));
693        assert_eq!(ProjectPath::new("a/.."), Err(ProjectPathError::Empty));
694        assert_eq!(
695            ProjectPath::new("../b"),
696            Err(ProjectPathError::ParentEscape)
697        );
698        assert_eq!(
699            ProjectPath::new("a/../../b"),
700            Err(ProjectPathError::ParentEscape)
701        );
702        assert_eq!(ProjectPath::new("/a/b"), Err(ProjectPathError::Absolute));
703        assert_eq!(ProjectPath::new(r"C:\a\b"), Err(ProjectPathError::Absolute));
704    }
705
706    #[test]
707    fn canonical_path_text_is_available() {
708        assert_eq!(path_text(ProjectPath::new("a//b/")), Ok("a/b".to_owned()));
709    }
710
711    #[test]
712    fn equal_inputs_produce_equal_ids() {
713        assert_eq!(
714            DependencyId::bibliography("refs.bib"),
715            DependencyId::bibliography("refs.bib")
716        );
717    }
718
719    #[test]
720    fn distinct_inputs_and_kinds_differ() {
721        // Same path, different kind: not equal.
722        assert_ne!(DependencyId::source_file("x"), DependencyId::asset("x"));
723        // Same kind, different payload: not equal.
724        assert_ne!(DependencyId::label("a"), DependencyId::label("b"));
725    }
726
727    #[test]
728    fn ids_are_hashable_and_orderable() {
729        let mut set = HashSet::new();
730        assert!(set.insert(DependencyId::label("a")));
731        assert!(!set.insert(DependencyId::label("a")));
732
733        // BTreeSet exercises Ord and yields a deterministic order.
734        let ordered: BTreeSet<_> = [DependencyId::label("b"), DependencyId::label("a")]
735            .into_iter()
736            .collect();
737        let names: Vec<_> = ordered.iter().map(ToString::to_string).collect();
738        assert_eq!(names, ["label:a", "label:b"]);
739    }
740
741    #[test]
742    fn kind_tags_round_trip_through_display() {
743        for kind in [
744            DependencyKind::SourceFile,
745            DependencyKind::Asset,
746            DependencyKind::Bibliography,
747            DependencyKind::Label,
748        ] {
749            assert_eq!(kind.to_string(), kind.as_str());
750        }
751    }
752
753    #[test]
754    fn bibliography_dependency_is_always_bibliography_kind() {
755        let dep = BibliographyDependency::new("refs.bib", ContentHash(1));
756        assert_eq!(dep.map(|dep| dep.kind()), Ok(DependencyKind::Bibliography));
757    }
758
759    #[test]
760    fn bibliography_dependency_id_round_trips_to_bibliography_variant() {
761        assert_eq!(
762            BibliographyDependency::new("refs.bib", ContentHash(1)).map(|dep| dep.id()),
763            DependencyId::bibliography("refs.bib"),
764        );
765    }
766
767    #[test]
768    fn bibliography_dependency_exposes_path_and_content() {
769        let dep = BibliographyDependency::new("ch/refs.bib", ContentHash(0x99));
770        assert_eq!(
771            dep.as_ref().map(|dep| dep.path().as_str().to_owned()),
772            Ok("ch/refs.bib".to_owned()),
773        );
774        assert_eq!(dep.map(|dep| dep.content()), Ok(ContentHash(0x99)));
775    }
776
777    #[test]
778    fn equal_path_and_content_produce_equal_dependencies() {
779        // Path canonicalization is inherited from `ProjectPath`.
780        assert_eq!(
781            BibliographyDependency::new("./ch/../refs.bib", ContentHash(7)),
782            BibliographyDependency::new("refs.bib", ContentHash(7)),
783        );
784    }
785
786    #[test]
787    fn differing_content_or_path_makes_dependencies_differ() {
788        // Same path, different content boundary: not equal.
789        assert_ne!(
790            BibliographyDependency::new("refs.bib", ContentHash(1)),
791            BibliographyDependency::new("refs.bib", ContentHash(2)),
792        );
793        // Same content, different path: not equal.
794        assert_ne!(
795            BibliographyDependency::new("a.bib", ContentHash(1)),
796            BibliographyDependency::new("b.bib", ContentHash(1)),
797        );
798    }
799
800    #[test]
801    fn bibliography_dependencies_are_hashable_and_orderable() {
802        let built: Result<Vec<_>, _> = [
803            ("refs.bib", ContentHash(1)),
804            ("refs.bib", ContentHash(1)), // exact duplicate of the first
805            ("refs.bib", ContentHash(2)), // same path, distinct content boundary
806            ("b.bib", ContentHash(1)),
807            ("a.bib", ContentHash(1)),
808        ]
809        .into_iter()
810        .map(|(path, content)| BibliographyDependency::new(path, content))
811        .collect();
812
813        // HashSet dedups by value: 5 inputs, one exact duplicate -> 4 unique.
814        let unique = built
815            .as_ref()
816            .map(|deps| deps.iter().cloned().collect::<HashSet<_>>().len());
817        assert_eq!(unique, Ok(4));
818
819        // BTreeSet exercises Ord and yields a deterministic order, sorted by
820        // (path, content). Project the full key so the content tie-break is
821        // actually asserted: the two refs.bib entries must order 1 before 2.
822        let ordered = built.as_ref().map(|deps| {
823            deps.iter()
824                .cloned()
825                .collect::<BTreeSet<_>>()
826                .iter()
827                .map(|dep| (dep.path().as_str().to_owned(), dep.content()))
828                .collect::<Vec<_>>()
829        });
830        assert_eq!(
831            ordered,
832            Ok(vec![
833                ("a.bib".to_owned(), ContentHash(1)),
834                ("b.bib".to_owned(), ContentHash(1)),
835                ("refs.bib".to_owned(), ContentHash(1)),
836                ("refs.bib".to_owned(), ContentHash(2)),
837            ])
838        );
839    }
840
841    #[test]
842    fn invalid_path_is_rejected() {
843        assert_eq!(
844            BibliographyDependency::new("../escape.bib", ContentHash(1)),
845            Err(ProjectPathError::ParentEscape),
846        );
847    }
848}