Skip to main content

asciidoc_parser/document/
catalog.rs

1use std::collections::HashMap;
2
3use crate::{content::FootnoteDeferred, internal::debug::DebugHashMapFrom, parser::XrefSignifier};
4
5/// Document catalog for tracking referenceable elements.
6///
7/// The catalog maintains a registry of all elements that can be referenced
8/// via cross-references, including anchors, sections, and bibliography entries.
9/// It provides functionality for registering new references, resolving
10/// reference text to IDs, and detecting duplicate IDs.
11#[derive(Clone, Eq, PartialEq)]
12pub struct Catalog {
13    /// Primary registry mapping IDs to reference entries.
14    pub(crate) refs: HashMap<String, RefEntry>,
15
16    /// Reverse lookup cache: reftext -> ID.
17    pub(crate) reftext_to_id: HashMap<String, String>,
18
19    /// Footnotes registered (in document order) while substituting inline
20    /// macros. Each entry corresponds to a `footnote:[…]` macro that *defined*
21    /// a footnote; subsequent references to an existing footnote (via a
22    /// repeated ID) reuse an entry rather than adding a new one.
23    ///
24    /// A nested document (an AsciiDoc table cell) keeps its own footnote list:
25    /// footnotes defined inside a cell are *not* shared with the main document.
26    pub(crate) footnotes: Vec<Footnote>,
27
28    /// Images referenced by `image:`/`image::` macros, recorded in document
29    /// order while substituting inline macros – but only when the parser was
30    /// configured with
31    /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets)
32    /// (Asciidoctor's `catalog_assets` API option). Empty otherwise.
33    pub(crate) images: Vec<ImageReference>,
34
35    /// Link targets referenced by `link:`/`mailto:` macros and by bare URL and
36    /// email autolinks, recorded in document order while substituting inline
37    /// macros – but only when the parser was configured with
38    /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets)
39    /// (Asciidoctor's `catalog_assets` API option). Empty otherwise.
40    ///
41    /// Each entry is the final link target as it appears in the rendered `href`
42    /// (e.g. `https://example.org`, `mailto:fred@example.com`), matching
43    /// Asciidoctor's `catalog[:links]`.
44    pub(crate) links: Vec<String>,
45
46    /// AsciiDoc files that were included into this document, keyed by the
47    /// include target relative to the outermost document with its AsciiDoc
48    /// extension removed (e.g. `other-chapters` for
49    /// `include::other-chapters.adoc[]`). The value records whether the file
50    /// was ever included *in full*: `true` when at least one include merged the
51    /// whole file, `false` when every include of it selected only a
52    /// `lines`/`tag(s)` portion.
53    ///
54    /// The preprocessor records each include while it expands `include::`
55    /// directives (before parsing); `Parser::parse_deferred` folds those into
56    /// this map via [`register_include`](Self::register_include), and it
57    /// survives into the document's catalog. It lets an
58    /// inter-document cross reference whose target names an included file
59    /// collapse to a same-document reference — the target's anchors are now
60    /// part of *this* document — but only when the file was included in
61    /// full, since a partial include may not have carried the referenced
62    /// anchor across. See [`interpret_xref_target`](crate::content).
63    pub(crate) includes: HashMap<String, bool>,
64}
65
66impl Default for Catalog {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl Catalog {
73    pub(crate) fn new() -> Self {
74        Self {
75            refs: HashMap::new(),
76            reftext_to_id: HashMap::new(),
77            footnotes: Vec::new(),
78            images: Vec::new(),
79            links: Vec::new(),
80            includes: HashMap::new(),
81        }
82    }
83
84    /// Register a new referenceable element in the catalog.
85    ///
86    /// # Arguments
87    /// * `id` - The unique identifier for the element
88    /// * `reftext` - Optional reference text for the element
89    /// * `ref_type` - Type of referenceable element
90    ///
91    /// # Returns
92    /// * `Ok(())` if the element was successfully registered
93    /// * `Err(DuplicateIdError)` if the ID is already in use
94    pub(crate) fn register_ref(
95        &mut self,
96        id: &str,
97        reftext: Option<&str>,
98        ref_type: RefType,
99    ) -> Result<(), DuplicateIdError> {
100        if self.refs.contains_key(id) {
101            return Err(DuplicateIdError(id.to_string()));
102        }
103
104        let entry = RefEntry {
105            id: id.to_string(),
106            reftext: reftext.map(|s| s.to_owned()),
107            ref_type,
108            signifier: None,
109        };
110
111        self.refs.insert(id.to_string(), entry);
112
113        if let Some(reftext) = reftext {
114            self.reftext_to_id
115                .entry(reftext.to_string())
116                .or_insert_with(|| id.to_string());
117        }
118
119        Ok(())
120    }
121
122    /// Generate a unique ID based on a base ID and register it in the catalog.
123    ///
124    /// If the base ID is not in use, it is returned as-is. Otherwise, numeric
125    /// suffixes are appended until a unique ID is found. The generated ID is
126    /// then registered in the catalog with the provided parameters.
127    ///
128    /// # Arguments
129    /// * `base_id` - The base identifier to use
130    /// * `reftext` - Optional reference text for the element
131    /// * `ref_type` - Type of referenceable element
132    ///
133    /// # Returns
134    /// The unique ID that was generated and registered.
135    pub(crate) fn generate_and_register_unique_id(
136        &mut self,
137        base_id: &str,
138        reftext: Option<&str>,
139        ref_type: RefType,
140        separator: &str,
141    ) -> String {
142        let unique_id = if !self.contains_id(base_id) {
143            base_id.to_string()
144        } else {
145            let mut counter = 2;
146            loop {
147                let candidate = format!("{base_id}{separator}{counter}");
148                if !self.contains_id(&candidate) {
149                    break candidate;
150                }
151                counter += 1;
152            }
153        };
154
155        // Register the generated unique ID.
156        let entry = RefEntry {
157            id: unique_id.clone(),
158            reftext: reftext.map(|s| s.to_owned()),
159            ref_type,
160            signifier: None,
161        };
162
163        self.refs.insert(unique_id.clone(), entry);
164
165        if let Some(reftext) = reftext {
166            self.reftext_to_id
167                .entry(reftext.to_string())
168                .or_insert_with(|| unique_id.clone());
169        }
170
171        unique_id
172    }
173
174    /// Returns a reference entry by ID, if it exists.
175    pub fn get_ref(&self, id: &str) -> Option<&RefEntry> {
176        self.refs.get(id)
177    }
178
179    /// Returns `true` if an ID is already registered in the catalog.
180    pub fn contains_id(&self, id: &str) -> bool {
181        self.refs.contains_key(id)
182    }
183
184    /// Resolve reference text to an ID, if possible.
185    pub fn resolve_id(&self, reftext: &str) -> Option<String> {
186        self.reftext_to_id.get(reftext).cloned()
187    }
188
189    /// Attaches an [`XrefSignifier`] to an already-registered element, so a
190    /// cross-reference to it can build `full`/`short`
191    /// [`xrefstyle`](crate::parser::XrefStyle) text. A no-op if `id` is not
192    /// registered.
193    pub(crate) fn set_signifier(&mut self, id: &str, signifier: XrefSignifier) {
194        if let Some(entry) = self.refs.get_mut(id) {
195            entry.signifier = Some(signifier);
196        }
197    }
198
199    /// Returns an iterator over all registered reference IDs, in an
200    /// unspecified order.
201    ///
202    /// This lets a multi-document pipeline enumerate a document's anchors and
203    /// section IDs (for example, to build a global cross-reference index)
204    /// without re-walking the block tree.
205    pub fn ids(&self) -> impl Iterator<Item = &str> {
206        self.refs.keys().map(String::as_str)
207    }
208
209    /// Returns an iterator over all registered reference entries, in an
210    /// unspecified order.
211    ///
212    /// Each item pairs an ID with its [`RefEntry`] (which also carries the
213    /// entry's reftext and [`RefType`]).
214    pub fn entries(&self) -> impl Iterator<Item = (&str, &RefEntry)> {
215        self.refs.iter().map(|(id, entry)| (id.as_str(), entry))
216    }
217
218    /// Returns the number of registered references.
219    pub fn len(&self) -> usize {
220        self.refs.len()
221    }
222
223    /// Returns `true` if the catalog contains no registered references.
224    pub fn is_empty(&self) -> bool {
225        self.refs.is_empty()
226    }
227
228    /// Returns the footnotes registered in this document, in document order.
229    pub fn footnotes(&self) -> &[Footnote] {
230        &self.footnotes
231    }
232
233    /// Registers a newly-defined [`Footnote`].
234    pub(crate) fn register_footnote(&mut self, footnote: Footnote) {
235        self.footnotes.push(footnote);
236    }
237
238    /// Returns the registered footnote with the given ID, if one exists.
239    pub(crate) fn footnote_with_id(&self, id: &str) -> Option<&Footnote> {
240        self.footnotes.iter().find(|f| f.id.as_deref() == Some(id))
241    }
242
243    /// Returns the images referenced in this document, in document order.
244    ///
245    /// This list is populated only when the parser was configured with
246    /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets); it is
247    /// empty otherwise.
248    pub fn images(&self) -> &[ImageReference] {
249        &self.images
250    }
251
252    /// Records a referenced image (an `image:`/`image::` macro target) in
253    /// document order.
254    pub(crate) fn register_image(&mut self, target: String, imagesdir: Option<String>) {
255        self.images.push(ImageReference { target, imagesdir });
256    }
257
258    /// Returns the link targets referenced in this document, in document order.
259    ///
260    /// This list is populated only when the parser was configured with
261    /// [`with_catalog_assets(true)`](crate::Parser::with_catalog_assets); it is
262    /// empty otherwise.
263    pub fn links(&self) -> &[String] {
264        &self.links
265    }
266
267    /// Records a referenced link target (a `link:`/`mailto:` macro or an
268    /// autolinked bare URL or email address) in document order.
269    pub(crate) fn register_link(&mut self, target: String) {
270        self.links.push(target);
271    }
272
273    /// Records that the AsciiDoc file named by `key` was included into this
274    /// document.
275    ///
276    /// `key` is the include target relative to the outermost document, with its
277    /// AsciiDoc extension removed (e.g. `other-chapters`). `full` is `true`
278    /// when the entire file was included and `false` when only a
279    /// `lines`/`tag(s)` selection of it was.
280    ///
281    /// A file included in full at least once is recorded as full even if it was
282    /// also included partially (a full include always carries every anchor
283    /// across), matching Asciidoctor.
284    pub(crate) fn register_include(&mut self, key: &str, full: bool) {
285        self.includes
286            .entry(key.to_string())
287            .and_modify(|existing| *existing |= full)
288            .or_insert(full);
289    }
290
291    /// Returns `true` if the file named by `key` (an include target relative to
292    /// the outermost document, without its AsciiDoc extension) was included
293    /// into this document *in full* — i.e. at least one `include::`
294    /// directive merged the whole file, rather than only a `lines`/`tag(s)`
295    /// portion of it.
296    ///
297    /// Returns `false` if the file was only ever partially included, or was not
298    /// included at all.
299    pub fn include_is_full(&self, key: &str) -> bool {
300        self.includes.get(key).copied().unwrap_or(false)
301    }
302
303    /// Returns `true` if the file named by `key` (an include target relative to
304    /// the outermost document, without its AsciiDoc extension) was included
305    /// into this document, whether in full or only partially.
306    pub fn was_included(&self, key: &str) -> bool {
307        self.includes.contains_key(key)
308    }
309
310    /// Removes and returns the current footnote list, leaving an empty list
311    /// behind. Used to give a nested document (an AsciiDoc table cell) its own
312    /// footnote registry so its footnotes are not shared with the enclosing
313    /// document.
314    pub(crate) fn take_footnotes(&mut self) -> Vec<Footnote> {
315        std::mem::take(&mut self.footnotes)
316    }
317
318    /// Restores a previously-[taken](Self::take_footnotes) footnote list,
319    /// discarding any footnotes registered in the meantime.
320    pub(crate) fn restore_footnotes(&mut self, footnotes: Vec<Footnote>) {
321        self.footnotes = footnotes;
322    }
323}
324
325/// A footnote registered while substituting the inline `footnote:[…]` macro.
326///
327/// A footnote is defined at the location of its reference, but its text is
328/// extracted to an item in the document's footnote list. The same footnote can
329/// be referenced from multiple locations by assigning it an ID at the first
330/// occurrence and repeating that ID (with empty text) afterward; only the
331/// defining occurrence produces a `Footnote` entry.
332#[derive(Clone, Eq, PartialEq)]
333pub struct Footnote {
334    /// The footnote's number, assigned in document order via the
335    /// `footnote-number` counter. Normally a consecutive integer (`1`, `2`, …),
336    /// but stored as a string because the counter honors any seed the document
337    /// sets (e.g. `:footnote-number: z` yields `aa`, `ab`, … as Asciidoctor
338    /// does).
339    pub index: String,
340
341    /// The optional ID assigned to this footnote (the target of the macro, e.g.
342    /// `disclaimer` in `footnote:disclaimer[…]`). `None` for an anonymous
343    /// footnote.
344    pub id: Option<String>,
345
346    /// The already-substituted text of the footnote. When the footnote contains
347    /// cross-references, this reflects the unresolved fallback rendering until
348    /// the document's references are resolved, after which it reflects the
349    /// resolved links; it is always clean, user-facing text.
350    pub text: String,
351
352    /// Deferred cross-references discovered in the footnote text, awaiting
353    /// resolution. `None` for the common case of a footnote with no
354    /// cross-references.
355    pub(crate) deferred: Option<Box<FootnoteDeferred>>,
356
357    /// The location of this footnote's defining occurrence, as a
358    /// `(byte offset, byte length)` pair into the document source, used to
359    /// anchor a cross-reference warning at the footnote rather than at the
360    /// whole document. The range spans the enclosing content the footnote was
361    /// written in (paragraph granularity, matching how a non-footnote
362    /// reference is anchored at its `Content`).
363    ///
364    /// `None` when the defining occurrence is not locatable in the document
365    /// source: a footnote defined while substituting a privately-owned
366    /// sub-source (a Markdown-style blockquote, an AsciiDoc table cell) indexes
367    /// that owned source, which is not contiguous in the document, so storing
368    /// its offset would misplace the warning. Resolution falls back to the
369    /// whole-document span in that case.
370    pub(crate) location: Option<(usize, usize)>,
371}
372
373impl Footnote {
374    /// Resolves any cross-references embedded in this footnote's text using
375    /// `resolver`, then rebuilds [`text`](Self::text) from the resolved state.
376    /// Any unresolved target is reported in `warnings`.
377    ///
378    /// A footnote's text is extracted out of the block it was defined in, so
379    /// the warning is anchored using the footnote's recorded
380    /// [`location`](Self::location) — the enclosing content it was written in —
381    /// reconstructed as a sub-span of `document_source`. When no location was
382    /// recorded (a footnote defined inside an owned sub-source, whose offset
383    /// does not map to the document), the warning falls back to the whole
384    /// `document_source` span.
385    ///
386    /// A footnote with no cross-references is left untouched.
387    pub(crate) fn resolve_references<'src>(
388        &mut self,
389        resolver: &dyn crate::parser::ReferenceResolver,
390        renderer: &dyn crate::parser::InlineSubstitutionRenderer,
391        warnings: &mut crate::parser::ReferenceWarnings<'src>,
392        document_source: crate::Span<'src>,
393    ) {
394        if let Some(deferred) = self.deferred.as_mut() {
395            let source = match self.location {
396                Some((offset, len)) => document_source.slice(offset..offset + len),
397                None => document_source,
398            };
399            deferred.resolve(resolver, warnings, source);
400            self.text = deferred.render(renderer);
401        }
402    }
403}
404
405impl std::fmt::Debug for Footnote {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        // The deferred cross-reference state is an internal implementation
408        // detail, omitted unless present so that the (very common)
409        // cross-reference-free footnote debugs as a plain field set.
410        let mut s = f.debug_struct("Footnote");
411        s.field("index", &self.index);
412        s.field("id", &self.id);
413        s.field("text", &self.text);
414
415        if let Some(deferred) = self.deferred.as_ref() {
416            s.field("deferred", deferred);
417        }
418
419        s.finish()
420    }
421}
422
423impl std::fmt::Debug for Catalog {
424    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
425        f.debug_struct("Catalog")
426            .field("refs", &DebugHashMapFrom(&self.refs))
427            .field("reftext_to_id", &DebugHashMapFrom(&self.reftext_to_id))
428            .field("footnotes", &self.footnotes)
429            .field("images", &self.images)
430            .field("links", &self.links)
431            .field("includes", &DebugHashMapFrom(&self.includes))
432            .finish()
433    }
434}
435
436/// A reference to an image asset recorded in the document
437/// [`Catalog`](Catalog::images) when `catalog_assets` is enabled.
438///
439/// Mirrors Asciidoctor's `Document::ImageReference`: it pairs the image
440/// [`target`](Self::target) with the value of the `imagesdir` attribute in
441/// effect where the image was referenced.
442#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct ImageReference {
444    /// The image target as written in the macro, after attribute references in
445    /// the target have been substituted (e.g. `fixtures/dot.gif`).
446    pub target: String,
447
448    /// The value of the `imagesdir` document attribute at the point of
449    /// reference, or `None` when it was unset.
450    pub imagesdir: Option<String>,
451}
452
453impl std::fmt::Display for ImageReference {
454    /// Displays the image reference as its [`target`](Self::target), mirroring
455    /// Asciidoctor's `ImageReference#to_s`.
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        f.write_str(&self.target)
458    }
459}
460
461/// Type of referenceable element in the document.
462#[derive(Clone, PartialEq, Eq)]
463pub enum RefType {
464    /// Standard anchor element (`[[id]]` or `[[id,reftext]]`).
465    Anchor,
466
467    /// Section heading that can be referenced.
468    Section,
469
470    /// Bibliography reference (`[[[id]]]` or `[[[id,reftext]]]`).
471    Bibliography,
472}
473
474impl std::fmt::Debug for RefType {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        match self {
477            Self::Anchor => f.write_str("RefType::Anchor"),
478            Self::Section => f.write_str("RefType::Section"),
479            Self::Bibliography => f.write_str("RefType::Bibliography"),
480        }
481    }
482}
483
484/// Entry in the document catalog representing a referenceable element.
485#[derive(Clone, Debug, Eq, PartialEq)]
486pub struct RefEntry {
487    /// The unique identifier for this element.
488    pub id: String,
489
490    /// Reference text for this element (explicit or computed).
491    pub reftext: Option<String>,
492
493    /// Type of referenceable element.
494    pub ref_type: RefType,
495
496    /// The signifier and number used to build `full`/`short`
497    /// [`xrefstyle`](crate::parser::XrefStyle) cross-reference text for this
498    /// target. Present only for a numbered section or captioned block that has
499    /// no explicit reftext; `None` for every other element (plain anchors,
500    /// bibliography entries, unnumbered sections, and targets carrying an
501    /// explicit reftext, for which `xrefstyle` formatting does not apply).
502    pub signifier: Option<XrefSignifier>,
503}
504
505/// Error that occurs when attempting to register a duplicate ID.
506#[derive(Clone, Debug, Eq, PartialEq)]
507pub(crate) struct DuplicateIdError(pub(crate) String);
508
509impl std::fmt::Display for DuplicateIdError {
510    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511        write!(f, "ID '{}' already registered", self.0)
512    }
513}
514
515impl std::error::Error for DuplicateIdError {}
516
517#[cfg(test)]
518mod tests {
519    #![allow(clippy::indexing_slicing, clippy::unwrap_used)]
520
521    use super::*;
522
523    #[test]
524    fn new_catalog_is_empty() {
525        let catalog = Catalog::new();
526        assert!(catalog.is_empty());
527        assert_eq!(catalog.len(), 0);
528    }
529
530    #[test]
531    fn register_ref_success() {
532        let mut catalog = Catalog::new();
533
534        let result = catalog.register_ref("test-id", Some("Test Reference"), RefType::Anchor);
535
536        assert!(result.is_ok());
537        assert_eq!(catalog.len(), 1);
538        assert!(catalog.contains_id("test-id"));
539    }
540
541    #[test]
542    fn register_duplicate_id_fails() {
543        let mut catalog = Catalog::new();
544
545        // Register first reference.
546        catalog
547            .register_ref("test-id", Some("First"), RefType::Anchor)
548            .unwrap();
549
550        // Attempt to register duplicate.
551        let result = catalog.register_ref("test-id", Some("Second"), RefType::Section);
552
553        let error = result.unwrap_err();
554        assert_eq!(error.0, "test-id");
555    }
556
557    #[test]
558    fn generate_and_register_unique_id() {
559        let mut catalog = Catalog::new();
560
561        // Test with available ID.
562        let id1 = catalog.generate_and_register_unique_id(
563            "available",
564            Some("Available Ref"),
565            RefType::Anchor,
566            "-",
567        );
568        assert_eq!(id1, "available");
569        assert!(catalog.contains_id("available"));
570        assert_eq!(
571            catalog.resolve_id("Available Ref"),
572            Some("available".to_string())
573        );
574
575        // Test with taken IDs.
576        catalog
577            .register_ref("taken", None, RefType::Anchor)
578            .unwrap();
579        catalog
580            .register_ref("taken-2", None, RefType::Anchor)
581            .unwrap();
582
583        let id2 = catalog.generate_and_register_unique_id("taken", None, RefType::Section, "-");
584        assert_eq!(id2, "taken-3");
585        assert!(catalog.contains_id("taken-3"));
586    }
587
588    #[test]
589    fn get_ref() {
590        let mut catalog = Catalog::new();
591
592        catalog
593            .register_ref("test-id", Some("Test Reference"), RefType::Bibliography)
594            .unwrap();
595
596        let entry = catalog.get_ref("test-id").unwrap();
597        assert_eq!(entry.id, "test-id");
598        assert_eq!(entry.reftext, Some("Test Reference".to_string()));
599        assert_eq!(entry.ref_type, RefType::Bibliography);
600
601        assert!(catalog.get_ref("nonexistent").is_none());
602    }
603
604    #[test]
605    fn enumerate_ids_and_entries() {
606        let mut catalog = Catalog::new();
607
608        catalog
609            .register_ref("intro", Some("Introduction"), RefType::Section)
610            .unwrap();
611        catalog
612            .register_ref("fig-1", None, RefType::Anchor)
613            .unwrap();
614
615        // `ids()` enumerates every registered ID (order is unspecified).
616        let mut ids: Vec<&str> = catalog.ids().collect();
617        ids.sort_unstable();
618        assert_eq!(ids, vec!["fig-1", "intro"]);
619
620        // `entries()` pairs each ID with its full entry.
621        let entries: Vec<(&str, &RefEntry)> = catalog.entries().collect();
622        assert_eq!(entries.len(), 2);
623
624        let (fig_id, fig_entry) = entries.iter().find(|(id, _)| *id == "fig-1").unwrap();
625        assert_eq!(*fig_id, "fig-1");
626        assert_eq!(fig_entry.id, "fig-1");
627        assert_eq!(fig_entry.reftext, None);
628        assert_eq!(fig_entry.ref_type, RefType::Anchor);
629
630        let (_, intro_entry) = entries.iter().find(|(id, _)| *id == "intro").unwrap();
631        assert_eq!(intro_entry.reftext, Some("Introduction".to_string()));
632        assert_eq!(intro_entry.ref_type, RefType::Section);
633    }
634
635    #[test]
636    fn resolve_id() {
637        let mut catalog = Catalog::new();
638
639        catalog
640            .register_ref("anchor1", Some("Reference Text"), RefType::Anchor)
641            .unwrap();
642
643        catalog
644            .register_ref("anchor2", Some("Another Reference"), RefType::Section)
645            .unwrap();
646
647        assert_eq!(
648            catalog.resolve_id("Reference Text"),
649            Some("anchor1".to_string())
650        );
651        assert_eq!(
652            catalog.resolve_id("Another Reference"),
653            Some("anchor2".to_string())
654        );
655        assert_eq!(catalog.resolve_id("Nonexistent"), None);
656    }
657
658    #[test]
659    fn resolve_id_first_wins_on_duplicates() {
660        let mut catalog = Catalog::new();
661
662        // Register two different IDs with same reftext.
663        catalog
664            .register_ref("first", Some("Same Text"), RefType::Anchor)
665            .unwrap();
666
667        catalog
668            .register_ref("second", Some("Same Text"), RefType::Section)
669            .unwrap();
670
671        assert_eq!(catalog.resolve_id("Same Text"), Some("first".to_string()));
672    }
673
674    #[test]
675    fn register_include_records_full_and_partial() {
676        let mut catalog = Catalog::new();
677
678        // An unregistered file is neither included nor full.
679        assert!(!catalog.was_included("tigers"));
680        assert!(!catalog.include_is_full("tigers"));
681
682        catalog.register_include("tigers", false);
683        assert!(catalog.was_included("tigers"));
684        assert!(!catalog.include_is_full("tigers"));
685
686        catalog.register_include("lions", true);
687        assert!(catalog.was_included("lions"));
688        assert!(catalog.include_is_full("lions"));
689    }
690
691    #[test]
692    fn a_full_include_wins_over_a_partial_one_in_either_order() {
693        // partial then full → full
694        let mut catalog = Catalog::new();
695        catalog.register_include("tigers", false);
696        catalog.register_include("tigers", true);
697        assert!(catalog.include_is_full("tigers"));
698
699        // full then partial → still full
700        let mut catalog = Catalog::new();
701        catalog.register_include("tigers", true);
702        catalog.register_include("tigers", false);
703        assert!(catalog.include_is_full("tigers"));
704
705        // partial then partial → partial
706        let mut catalog = Catalog::new();
707        catalog.register_include("tigers", false);
708        catalog.register_include("tigers", false);
709        assert!(catalog.was_included("tigers"));
710        assert!(!catalog.include_is_full("tigers"));
711    }
712
713    #[test]
714    fn register_image_records_in_document_order() {
715        let mut catalog = Catalog::new();
716        assert!(catalog.images().is_empty());
717
718        catalog.register_image("fixtures/dot.gif".to_string(), None);
719        catalog.register_image("logo.png".to_string(), Some("images".to_string()));
720
721        let images = catalog.images();
722        assert_eq!(images.len(), 2);
723
724        // The first image carries no `imagesdir`; `to_string`/`Display` yields
725        // the bare target.
726        assert_eq!(images[0].target, "fixtures/dot.gif");
727        assert_eq!(images[0].imagesdir, None);
728        assert_eq!(images[0].to_string(), "fixtures/dot.gif");
729
730        // The second records the `imagesdir` in effect at the reference.
731        assert_eq!(images[1].target, "logo.png");
732        assert_eq!(images[1].imagesdir, Some("images".to_string()));
733        assert_eq!(images[1].to_string(), "logo.png");
734    }
735
736    #[test]
737    fn register_link_records_in_document_order() {
738        let mut catalog = Catalog::new();
739        assert!(catalog.links().is_empty());
740
741        catalog.register_link("https://example.org".to_string());
742        catalog.register_link("mailto:fred@example.com".to_string());
743
744        assert_eq!(
745            catalog.links(),
746            ["https://example.org", "mailto:fred@example.com"]
747        );
748    }
749
750    #[test]
751    fn duplicate_id_error_impl_display() {
752        let did_error = DuplicateIdError("foo".to_string());
753        assert_eq!(did_error.to_string(), "ID 'foo' already registered");
754    }
755
756    #[test]
757    fn ref_type_impl_debug() {
758        assert_eq!(format!("{:#?}", RefType::Anchor), "RefType::Anchor");
759        assert_eq!(format!("{:#?}", RefType::Section), "RefType::Section");
760
761        assert_eq!(
762            format!("{:#?}", RefType::Bibliography),
763            "RefType::Bibliography"
764        );
765    }
766}