Skip to main content

asciidoc_parser/document/
catalog.rs

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