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    /// # Examples
167    ///
168    /// ```
169    /// use mos_cache::ProjectPath;
170    ///
171    /// // Decomposed "é" (e + combining acute) folds to the composed form.
172    /// assert_eq!(ProjectPath::new("e\u{0301}.bib"), ProjectPath::new("\u{00e9}.bib"));
173    /// ```
174    pub fn new(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
175        normalize(path.as_ref()).map(Self)
176    }
177
178    /// The canonical path string.
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// use mos_cache::ProjectPath;
184    ///
185    /// assert_eq!(
186    ///     ProjectPath::new("a//b/").map(|path| path.as_str().to_owned()),
187    ///     Ok("a/b".to_owned())
188    /// );
189    /// ```
190    #[must_use]
191    pub fn as_str(&self) -> &str {
192        &self.0
193    }
194}
195
196impl fmt::Display for ProjectPath {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        f.write_str(&self.0)
199    }
200}
201
202/// Lexically canonicalize a project-relative path: fold `\` to `/`, drop
203/// `.`/empty segments, resolve `..`, and NFC-normalize. No filesystem access.
204fn normalize(input: &str) -> Result<String, ProjectPathError> {
205    let forward = input.replace('\\', "/");
206    if forward.is_empty() {
207        return Err(ProjectPathError::Empty);
208    }
209    if forward.starts_with('/') || starts_with_windows_drive(&forward) {
210        return Err(ProjectPathError::Absolute);
211    }
212    let mut segments: Vec<&str> = Vec::new();
213    for segment in forward.split('/') {
214        match segment {
215            "" | "." => {}
216            ".." => match segments.last() {
217                // Pop a real parent segment.
218                Some(&last) if last != ".." => {
219                    segments.pop();
220                }
221                _ => return Err(ProjectPathError::ParentEscape),
222            },
223            other => segments.push(other),
224        }
225    }
226    let body: String = segments.join("/").nfc().collect();
227    if body.is_empty() {
228        return Err(ProjectPathError::Empty);
229    }
230    Ok(body)
231}
232
233fn starts_with_windows_drive(path: &str) -> bool {
234    let mut chars = path.chars();
235    matches!(
236        (chars.next(), chars.next()),
237        (Some(letter), Some(':')) if letter.is_ascii_alphabetic()
238    )
239}
240
241/// A typed, deterministic identity for one build dependency.
242///
243/// Each variant carries the payload appropriate to its [`DependencyKind`], so
244/// mismatched combinations cannot be constructed. File inputs use the canonical
245/// [`ProjectPath`]; labels use their name. The derived [`Eq`]/[`Ord`]/[`Hash`]
246/// make ids usable as keys in deterministic maps and sets; [`Display`] gives a
247/// stable `kind:payload` view for logs and debugging.
248///
249/// [`Display`]: core::fmt::Display
250///
251/// # Examples
252///
253/// ```
254/// use mos_cache::{DependencyId, DependencyKind};
255///
256/// # fn main() -> Result<(), mos_cache::ProjectPathError> {
257/// let bib = DependencyId::bibliography("./refs.bib")?;
258///
259/// assert_eq!(bib.kind(), DependencyKind::Bibliography);
260/// assert_eq!(bib.to_string(), "bibliography:refs.bib");
261/// assert_eq!(bib.path().map(|p| p.as_str()), Some("refs.bib"));
262/// # Ok(())
263/// # }
264/// ```
265#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
266pub enum DependencyId {
267    /// A `.mos` source file, identified by its canonical project path.
268    SourceFile(ProjectPath),
269    /// A referenced asset (such as an image), identified by its canonical path.
270    Asset(ProjectPath),
271    /// A bibliography input (such as a `.bib` file), by its canonical path.
272    Bibliography(ProjectPath),
273    /// A resolved label, identified by its reference name.
274    Label(String),
275}
276
277impl DependencyId {
278    /// A `.mos` source-file dependency.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use mos_cache::DependencyId;
284    ///
285    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
286    /// assert_eq!(DependencyId::source_file("a.mos")?.to_string(), "source:a.mos");
287    /// # Ok(())
288    /// # }
289    /// ```
290    pub fn source_file(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
291        ProjectPath::new(path).map(Self::SourceFile)
292    }
293
294    /// An asset dependency, such as an image.
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// use mos_cache::DependencyId;
300    ///
301    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
302    /// assert_eq!(DependencyId::asset("logo.png")?.to_string(), "asset:logo.png");
303    /// # Ok(())
304    /// # }
305    /// ```
306    pub fn asset(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
307        ProjectPath::new(path).map(Self::Asset)
308    }
309
310    /// A bibliography-input dependency, such as a `.bib` file.
311    ///
312    /// # Examples
313    ///
314    /// ```
315    /// use mos_cache::DependencyId;
316    ///
317    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
318    /// assert_eq!(DependencyId::bibliography("refs.bib")?.to_string(), "bibliography:refs.bib");
319    /// # Ok(())
320    /// # }
321    /// ```
322    pub fn bibliography(path: impl AsRef<str>) -> Result<Self, ProjectPathError> {
323        ProjectPath::new(path).map(Self::Bibliography)
324    }
325
326    /// A label dependency.
327    ///
328    /// # Examples
329    ///
330    /// ```
331    /// use mos_cache::DependencyId;
332    ///
333    /// assert_eq!(DependencyId::label("eq-1").to_string(), "label:eq-1");
334    /// ```
335    #[must_use]
336    pub fn label(name: impl Into<String>) -> Self {
337        Self::Label(name.into())
338    }
339
340    /// The [`DependencyKind`] this id belongs to.
341    ///
342    /// # Examples
343    ///
344    /// ```
345    /// use mos_cache::{DependencyId, DependencyKind};
346    ///
347    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
348    /// assert_eq!(DependencyId::asset("x.png")?.kind(), DependencyKind::Asset);
349    /// # Ok(())
350    /// # }
351    /// ```
352    #[must_use]
353    pub const fn kind(&self) -> DependencyKind {
354        match self {
355            Self::SourceFile(_) => DependencyKind::SourceFile,
356            Self::Asset(_) => DependencyKind::Asset,
357            Self::Bibliography(_) => DependencyKind::Bibliography,
358            Self::Label(_) => DependencyKind::Label,
359        }
360    }
361
362    /// The canonical path of a file-backed dependency, or [`None`] for labels.
363    ///
364    /// Covers the [`SourceFile`](Self::SourceFile), [`Asset`](Self::Asset), and
365    /// [`Bibliography`](Self::Bibliography) variants uniformly.
366    ///
367    /// # Examples
368    ///
369    /// ```
370    /// use mos_cache::DependencyId;
371    ///
372    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
373    /// assert_eq!(DependencyId::asset("logo.png")?.path().map(|p| p.as_str()), Some("logo.png"));
374    /// assert_eq!(DependencyId::label("eq-1").path(), None);
375    /// # Ok(())
376    /// # }
377    /// ```
378    #[must_use]
379    pub const fn path(&self) -> Option<&ProjectPath> {
380        match self {
381            Self::SourceFile(path) | Self::Asset(path) | Self::Bibliography(path) => Some(path),
382            Self::Label(_) => None,
383        }
384    }
385}
386
387impl fmt::Display for DependencyId {
388    /// Renders a stable `kind:payload` view. Equality and hashing use the exact
389    /// payloads, which this string faithfully reflects for file paths (already
390    /// canonical) and labels.
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        write!(f, "{}:", self.kind())?;
393        match self {
394            Self::SourceFile(path) | Self::Asset(path) | Self::Bibliography(path) => {
395                f.write_str(path.as_str())
396            }
397            Self::Label(name) => f.write_str(name),
398        }
399    }
400}
401
402/// A bibliography input paired with its content-hash boundary.
403///
404/// [`DependencyId::Bibliography`] answers *which* `.bib` file this is (its
405/// canonical [`ProjectPath`]); the paired [`ContentHash`] answers *what was in
406/// it* at build time. Together they are what a future incremental engine needs
407/// to decide that cached citation data is stale: the id is the cache slot, the
408/// content hash is the staleness check (design note §4.1, §7).
409///
410/// Construction guarantees the id is always the [`Bibliography`] variant, so a
411/// `BibliographyDependency` cannot be built over a source/asset/label identity
412/// by mistake: [`path`] and [`kind`] are therefore infallible.
413///
414/// The content hash is supplied by the caller rather than computed here, which
415/// keeps `mos-cache` free of any bibliography-format knowledge. Produce it from
416/// the source bytes with `mos_bib::bibliography_content_hash`; `mos-eval` (which
417/// reads the `.bib` and already depends on both crates) is the natural wiring
418/// point.
419///
420/// [`Bibliography`]: DependencyId::Bibliography
421/// [`path`]: BibliographyDependency::path
422/// [`kind`]: BibliographyDependency::kind
423///
424/// # Examples
425///
426/// ```
427/// use mos_cache::{BibliographyDependency, DependencyId, DependencyKind};
428/// use mos_core::ContentHash;
429///
430/// # fn main() -> Result<(), mos_cache::ProjectPathError> {
431/// // The content hash would come from `mos_bib::bibliography_content_hash`.
432/// let dep = BibliographyDependency::new("./refs.bib", ContentHash(0x1234))?;
433///
434/// assert_eq!(dep.kind(), DependencyKind::Bibliography);
435/// assert_eq!(dep.id(), DependencyId::bibliography("refs.bib")?);
436/// assert_eq!(dep.path().as_str(), "refs.bib");
437/// assert_eq!(dep.content(), ContentHash(0x1234));
438/// # Ok(())
439/// # }
440/// ```
441#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
442pub struct BibliographyDependency {
443    path: ProjectPath,
444    content: ContentHash,
445}
446
447impl BibliographyDependency {
448    /// Pair a bibliography source `path` with the `content` hash of its bytes.
449    ///
450    /// The path is canonicalized into a [`ProjectPath`] (§3.1), so logically
451    /// equal paths yield equal dependencies; an invalid path returns
452    /// [`ProjectPathError`].
453    ///
454    /// # Examples
455    ///
456    /// ```
457    /// use mos_cache::BibliographyDependency;
458    /// use mos_core::ContentHash;
459    ///
460    /// // `./ch/../refs.bib` and `refs.bib` canonicalize to one identity.
461    /// assert_eq!(
462    ///     BibliographyDependency::new("./ch/../refs.bib", ContentHash(7)),
463    ///     BibliographyDependency::new("refs.bib", ContentHash(7)),
464    /// );
465    /// ```
466    pub fn new(path: impl AsRef<str>, content: ContentHash) -> Result<Self, ProjectPathError> {
467        ProjectPath::new(path).map(|path| Self { path, content })
468    }
469
470    /// The typed dependency identity (always the [`Bibliography`] variant).
471    ///
472    /// [`Bibliography`]: DependencyId::Bibliography
473    ///
474    /// # Examples
475    ///
476    /// ```
477    /// use mos_cache::{BibliographyDependency, DependencyId};
478    /// use mos_core::ContentHash;
479    ///
480    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
481    /// let dep = BibliographyDependency::new("refs.bib", ContentHash(1))?;
482    /// assert_eq!(dep.id(), DependencyId::bibliography("refs.bib")?);
483    /// # Ok(())
484    /// # }
485    /// ```
486    #[must_use]
487    pub fn id(&self) -> DependencyId {
488        DependencyId::Bibliography(self.path.clone())
489    }
490
491    /// The canonical project path of the bibliography source.
492    ///
493    /// # Examples
494    ///
495    /// ```
496    /// use mos_cache::BibliographyDependency;
497    /// use mos_core::ContentHash;
498    ///
499    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
500    /// assert_eq!(
501    ///     BibliographyDependency::new("refs.bib", ContentHash(1))?.path().as_str(),
502    ///     "refs.bib",
503    /// );
504    /// # Ok(())
505    /// # }
506    /// ```
507    #[must_use]
508    pub const fn path(&self) -> &ProjectPath {
509        &self.path
510    }
511
512    /// The content-hash boundary of the source bytes at build time.
513    ///
514    /// # Examples
515    ///
516    /// ```
517    /// use mos_cache::BibliographyDependency;
518    /// use mos_core::ContentHash;
519    ///
520    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
521    /// assert_eq!(
522    ///     BibliographyDependency::new("refs.bib", ContentHash(42))?.content(),
523    ///     ContentHash(42),
524    /// );
525    /// # Ok(())
526    /// # }
527    /// ```
528    #[must_use]
529    pub const fn content(&self) -> ContentHash {
530        self.content
531    }
532
533    /// The [`DependencyKind`] of this dependency: always
534    /// [`Bibliography`](DependencyKind::Bibliography).
535    ///
536    /// # Examples
537    ///
538    /// ```
539    /// use mos_cache::{BibliographyDependency, DependencyKind};
540    /// use mos_core::ContentHash;
541    ///
542    /// # fn main() -> Result<(), mos_cache::ProjectPathError> {
543    /// let dep = BibliographyDependency::new("refs.bib", ContentHash(1))?;
544    /// assert_eq!(dep.kind(), DependencyKind::Bibliography);
545    /// # Ok(())
546    /// # }
547    /// ```
548    #[must_use]
549    pub const fn kind(&self) -> DependencyKind {
550        DependencyKind::Bibliography
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use std::collections::{BTreeSet, HashSet};
557
558    use mos_core::ContentHash;
559
560    use super::{
561        BibliographyDependency, DependencyId, DependencyKind, ProjectPath, ProjectPathError,
562    };
563
564    fn id_text(id: Result<DependencyId, ProjectPathError>) -> Result<String, ProjectPathError> {
565        id.map(|id| id.to_string())
566    }
567
568    fn id_path_text(
569        id: Result<DependencyId, ProjectPathError>,
570    ) -> Result<Option<String>, ProjectPathError> {
571        id.map(|id| id.path().map(ProjectPath::as_str).map(str::to_owned))
572    }
573
574    fn path_text(path: Result<ProjectPath, ProjectPathError>) -> Result<String, ProjectPathError> {
575        path.map(|path| path.as_str().to_owned())
576    }
577
578    #[test]
579    fn kind_matches_variant() {
580        let cases = [
581            (
582                DependencyId::source_file("a.mos").map(|id| id.kind()),
583                Ok(DependencyKind::SourceFile),
584            ),
585            (
586                DependencyId::asset("a.png").map(|id| id.kind()),
587                Ok(DependencyKind::Asset),
588            ),
589            (
590                DependencyId::bibliography("a.bib").map(|id| id.kind()),
591                Ok(DependencyKind::Bibliography),
592            ),
593            (
594                Ok(DependencyId::label("a").kind()),
595                Ok(DependencyKind::Label),
596            ),
597        ];
598        for (actual, expected) in cases {
599            assert_eq!(actual, expected);
600        }
601    }
602
603    #[test]
604    fn display_is_stable_per_kind() {
605        assert_eq!(
606            id_text(DependencyId::source_file("ch/intro.mos")),
607            Ok("source:ch/intro.mos".to_owned())
608        );
609        assert_eq!(
610            id_text(DependencyId::asset("logo.png")),
611            Ok("asset:logo.png".to_owned())
612        );
613        assert_eq!(
614            id_text(DependencyId::bibliography("refs.bib")),
615            Ok("bibliography:refs.bib".to_owned())
616        );
617        assert_eq!(
618            DependencyId::label("eq-euler").to_string(),
619            "label:eq-euler"
620        );
621    }
622
623    #[test]
624    fn path_covers_file_variants_only() {
625        assert_eq!(
626            id_path_text(DependencyId::source_file("a.mos")),
627            Ok(Some("a.mos".to_owned()))
628        );
629        assert_eq!(
630            id_path_text(DependencyId::asset("a.png")),
631            Ok(Some("a.png".to_owned()))
632        );
633        assert_eq!(
634            id_path_text(DependencyId::bibliography("a.bib")),
635            Ok(Some("a.bib".to_owned()))
636        );
637        assert_eq!(DependencyId::label("a").path(), None);
638    }
639
640    #[test]
641    fn canonical_paths_collapse_to_one_identity() {
642        let canonical = DependencyId::source_file("ch/intro.mos");
643        for variant in [
644            "./ch/intro.mos",
645            "ch/./intro.mos",
646            "ch/../ch/intro.mos",
647            r"ch\intro.mos",
648        ] {
649            assert_eq!(DependencyId::source_file(variant), canonical, "{variant}");
650        }
651    }
652
653    #[test]
654    fn nfc_variants_share_one_identity() {
655        // Decomposed vs composed "é" must hash and compare equal.
656        assert_eq!(
657            DependencyId::bibliography("e\u{0301}.bib"),
658            DependencyId::bibliography("\u{00e9}.bib")
659        );
660    }
661
662    #[test]
663    fn invalid_project_paths_are_rejected() {
664        assert_eq!(ProjectPath::new(""), Err(ProjectPathError::Empty));
665        assert_eq!(ProjectPath::new("."), Err(ProjectPathError::Empty));
666        assert_eq!(ProjectPath::new("a/.."), Err(ProjectPathError::Empty));
667        assert_eq!(
668            ProjectPath::new("../b"),
669            Err(ProjectPathError::ParentEscape)
670        );
671        assert_eq!(
672            ProjectPath::new("a/../../b"),
673            Err(ProjectPathError::ParentEscape)
674        );
675        assert_eq!(ProjectPath::new("/a/b"), Err(ProjectPathError::Absolute));
676        assert_eq!(ProjectPath::new(r"C:\a\b"), Err(ProjectPathError::Absolute));
677    }
678
679    #[test]
680    fn canonical_path_text_is_available() {
681        assert_eq!(path_text(ProjectPath::new("a//b/")), Ok("a/b".to_owned()));
682    }
683
684    #[test]
685    fn equal_inputs_produce_equal_ids() {
686        assert_eq!(
687            DependencyId::bibliography("refs.bib"),
688            DependencyId::bibliography("refs.bib")
689        );
690    }
691
692    #[test]
693    fn distinct_inputs_and_kinds_differ() {
694        // Same path, different kind: not equal.
695        assert_ne!(DependencyId::source_file("x"), DependencyId::asset("x"));
696        // Same kind, different payload: not equal.
697        assert_ne!(DependencyId::label("a"), DependencyId::label("b"));
698    }
699
700    #[test]
701    fn ids_are_hashable_and_orderable() {
702        let mut set = HashSet::new();
703        assert!(set.insert(DependencyId::label("a")));
704        assert!(!set.insert(DependencyId::label("a")));
705
706        // BTreeSet exercises Ord and yields a deterministic order.
707        let ordered: BTreeSet<_> = [DependencyId::label("b"), DependencyId::label("a")]
708            .into_iter()
709            .collect();
710        let names: Vec<_> = ordered.iter().map(ToString::to_string).collect();
711        assert_eq!(names, ["label:a", "label:b"]);
712    }
713
714    #[test]
715    fn kind_tags_round_trip_through_display() {
716        for kind in [
717            DependencyKind::SourceFile,
718            DependencyKind::Asset,
719            DependencyKind::Bibliography,
720            DependencyKind::Label,
721        ] {
722            assert_eq!(kind.to_string(), kind.as_str());
723        }
724    }
725
726    #[test]
727    fn bibliography_dependency_is_always_bibliography_kind() {
728        let dep = BibliographyDependency::new("refs.bib", ContentHash(1));
729        assert_eq!(dep.map(|dep| dep.kind()), Ok(DependencyKind::Bibliography));
730    }
731
732    #[test]
733    fn bibliography_dependency_id_round_trips_to_bibliography_variant() {
734        assert_eq!(
735            BibliographyDependency::new("refs.bib", ContentHash(1)).map(|dep| dep.id()),
736            DependencyId::bibliography("refs.bib"),
737        );
738    }
739
740    #[test]
741    fn bibliography_dependency_exposes_path_and_content() {
742        let dep = BibliographyDependency::new("ch/refs.bib", ContentHash(0x99));
743        assert_eq!(
744            dep.as_ref().map(|dep| dep.path().as_str().to_owned()),
745            Ok("ch/refs.bib".to_owned()),
746        );
747        assert_eq!(dep.map(|dep| dep.content()), Ok(ContentHash(0x99)));
748    }
749
750    #[test]
751    fn equal_path_and_content_produce_equal_dependencies() {
752        // Path canonicalization is inherited from `ProjectPath`.
753        assert_eq!(
754            BibliographyDependency::new("./ch/../refs.bib", ContentHash(7)),
755            BibliographyDependency::new("refs.bib", ContentHash(7)),
756        );
757    }
758
759    #[test]
760    fn differing_content_or_path_makes_dependencies_differ() {
761        // Same path, different content boundary: not equal.
762        assert_ne!(
763            BibliographyDependency::new("refs.bib", ContentHash(1)),
764            BibliographyDependency::new("refs.bib", ContentHash(2)),
765        );
766        // Same content, different path: not equal.
767        assert_ne!(
768            BibliographyDependency::new("a.bib", ContentHash(1)),
769            BibliographyDependency::new("b.bib", ContentHash(1)),
770        );
771    }
772
773    #[test]
774    fn bibliography_dependencies_are_hashable_and_orderable() {
775        let built: Result<Vec<_>, _> = [
776            ("refs.bib", ContentHash(1)),
777            ("refs.bib", ContentHash(1)), // exact duplicate of the first
778            ("refs.bib", ContentHash(2)), // same path, distinct content boundary
779            ("b.bib", ContentHash(1)),
780            ("a.bib", ContentHash(1)),
781        ]
782        .into_iter()
783        .map(|(path, content)| BibliographyDependency::new(path, content))
784        .collect();
785
786        // HashSet dedups by value: 5 inputs, one exact duplicate -> 4 unique.
787        let unique = built
788            .as_ref()
789            .map(|deps| deps.iter().cloned().collect::<HashSet<_>>().len());
790        assert_eq!(unique, Ok(4));
791
792        // BTreeSet exercises Ord and yields a deterministic order, sorted by
793        // (path, content). Project the full key so the content tie-break is
794        // actually asserted: the two refs.bib entries must order 1 before 2.
795        let ordered = built.as_ref().map(|deps| {
796            deps.iter()
797                .cloned()
798                .collect::<BTreeSet<_>>()
799                .iter()
800                .map(|dep| (dep.path().as_str().to_owned(), dep.content()))
801                .collect::<Vec<_>>()
802        });
803        assert_eq!(
804            ordered,
805            Ok(vec![
806                ("a.bib".to_owned(), ContentHash(1)),
807                ("b.bib".to_owned(), ContentHash(1)),
808                ("refs.bib".to_owned(), ContentHash(1)),
809                ("refs.bib".to_owned(), ContentHash(2)),
810            ])
811        );
812    }
813
814    #[test]
815    fn invalid_path_is_rejected() {
816        assert_eq!(
817            BibliographyDependency::new("../escape.bib", ContentHash(1)),
818            Err(ProjectPathError::ParentEscape),
819        );
820    }
821}