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