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}