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}