Skip to main content

asciidoc_parser/document/
catalog.rs

1use std::collections::HashMap;
2
3use crate::internal::debug::DebugHashMapFrom;
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
20impl Default for Catalog {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl Catalog {
27    pub(crate) fn new() -> Self {
28        Self {
29            refs: HashMap::new(),
30            reftext_to_id: HashMap::new(),
31        }
32    }
33
34    /// Register a new referenceable element in the catalog.
35    ///
36    /// # Arguments
37    /// * `id` - The unique identifier for the element
38    /// * `reftext` - Optional reference text for the element
39    /// * `ref_type` - Type of referenceable element
40    ///
41    /// # Returns
42    /// * `Ok(())` if the element was successfully registered
43    /// * `Err(DuplicateIdError)` if the ID is already in use
44    pub(crate) fn register_ref(
45        &mut self,
46        id: &str,
47        reftext: Option<&str>,
48        ref_type: RefType,
49    ) -> Result<(), DuplicateIdError> {
50        if self.refs.contains_key(id) {
51            return Err(DuplicateIdError(id.to_string()));
52        }
53
54        let entry = RefEntry {
55            id: id.to_string(),
56            reftext: reftext.map(|s| s.to_owned()),
57            ref_type,
58        };
59
60        self.refs.insert(id.to_string(), entry);
61
62        if let Some(reftext) = reftext {
63            self.reftext_to_id
64                .entry(reftext.to_string())
65                .or_insert_with(|| id.to_string());
66        }
67
68        Ok(())
69    }
70
71    /// Generate a unique ID based on a base ID and register it in the catalog.
72    ///
73    /// If the base ID is not in use, it is returned as-is. Otherwise, numeric
74    /// suffixes are appended until a unique ID is found. The generated ID is
75    /// then registered in the catalog with the provided parameters.
76    ///
77    /// # Arguments
78    /// * `base_id` - The base identifier to use
79    /// * `reftext` - Optional reference text for the element
80    /// * `ref_type` - Type of referenceable element
81    ///
82    /// # Returns
83    /// The unique ID that was generated and registered.
84    pub(crate) fn generate_and_register_unique_id(
85        &mut self,
86        base_id: &str,
87        reftext: Option<&str>,
88        ref_type: RefType,
89    ) -> String {
90        let unique_id = if !self.contains_id(base_id) {
91            base_id.to_string()
92        } else {
93            let mut counter = 2;
94            loop {
95                let candidate = format!("{}-{}", base_id, counter);
96                if !self.contains_id(&candidate) {
97                    break candidate;
98                }
99                counter += 1;
100            }
101        };
102
103        // Register the generated unique ID.
104        let entry = RefEntry {
105            id: unique_id.clone(),
106            reftext: reftext.map(|s| s.to_owned()),
107            ref_type,
108        };
109
110        self.refs.insert(unique_id.clone(), entry);
111
112        if let Some(reftext) = reftext {
113            self.reftext_to_id
114                .entry(reftext.to_string())
115                .or_insert_with(|| unique_id.clone());
116        }
117
118        unique_id
119    }
120
121    /// Returns a reference entry by ID, if it exists.
122    pub fn get_ref(&self, id: &str) -> Option<&RefEntry> {
123        self.refs.get(id)
124    }
125
126    /// Returns `true` if an ID is already registered in the catalog.
127    pub fn contains_id(&self, id: &str) -> bool {
128        self.refs.contains_key(id)
129    }
130
131    /// Resolve reference text to an ID, if possible.
132    pub fn resolve_id(&self, reftext: &str) -> Option<String> {
133        self.reftext_to_id.get(reftext).cloned()
134    }
135
136    /* Disabling for now until I know if we'll need these.
137
138    /// Returns an iterator over all registered reference IDs.
139    pub fn ids(&self) -> impl Iterator<Item = &String> {
140        self.refs.keys()
141    }
142
143    /// Returns an iterator over all reference entries.
144    pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {
145        self.refs.iter()
146    }
147    */
148
149    /// Returns the number of registered references.
150    pub fn len(&self) -> usize {
151        self.refs.len()
152    }
153
154    /// Returns `true` if the catalog contains no registered references.
155    pub fn is_empty(&self) -> bool {
156        self.refs.is_empty()
157    }
158}
159
160impl std::fmt::Debug for Catalog {
161    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
162        f.debug_struct("Catalog")
163            .field("refs", &DebugHashMapFrom(&self.refs))
164            .field("reftext_to_id", &DebugHashMapFrom(&self.reftext_to_id))
165            .finish()
166    }
167}
168/// Type of referenceable element in the document.
169#[derive(Clone, PartialEq, Eq)]
170pub enum RefType {
171    /// Standard anchor element (`[[id]]` or `[[id,reftext]]`).
172    Anchor,
173
174    /// Section heading that can be referenced.
175    Section,
176
177    /// Bibliography reference (`[[[id]]]` or `[[[id,reftext]]]`).
178    Bibliography,
179}
180
181impl std::fmt::Debug for RefType {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            Self::Anchor => f.write_str("RefType::Anchor"),
185            Self::Section => f.write_str("RefType::Section"),
186            Self::Bibliography => f.write_str("RefType::Bibliography"),
187        }
188    }
189}
190
191/// Entry in the document catalog representing a referenceable element.
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct RefEntry {
194    /// The unique identifier for this element.
195    pub id: String,
196
197    /// Reference text for this element (explicit or computed).
198    pub reftext: Option<String>,
199
200    /// Type of referenceable element.
201    pub ref_type: RefType,
202}
203
204/// Error that occurs when attempting to register a duplicate ID.
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub(crate) struct DuplicateIdError(pub(crate) String);
207
208impl std::fmt::Display for DuplicateIdError {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        write!(f, "ID '{}' already registered", self.0)
211    }
212}
213
214impl std::error::Error for DuplicateIdError {}
215
216#[cfg(test)]
217mod tests {
218    #![allow(clippy::unwrap_used)]
219
220    use super::*;
221
222    #[test]
223    fn new_catalog_is_empty() {
224        let catalog = Catalog::new();
225        assert!(catalog.is_empty());
226        assert_eq!(catalog.len(), 0);
227    }
228
229    #[test]
230    fn register_ref_success() {
231        let mut catalog = Catalog::new();
232
233        let result = catalog.register_ref("test-id", Some("Test Reference"), RefType::Anchor);
234
235        assert!(result.is_ok());
236        assert_eq!(catalog.len(), 1);
237        assert!(catalog.contains_id("test-id"));
238    }
239
240    #[test]
241    fn register_duplicate_id_fails() {
242        let mut catalog = Catalog::new();
243
244        // Register first reference.
245        catalog
246            .register_ref("test-id", Some("First"), RefType::Anchor)
247            .unwrap();
248
249        // Attempt to register duplicate.
250        let result = catalog.register_ref("test-id", Some("Second"), RefType::Section);
251
252        let error = result.unwrap_err();
253        assert_eq!(error.0, "test-id");
254    }
255
256    #[test]
257    fn generate_and_register_unique_id() {
258        let mut catalog = Catalog::new();
259
260        // Test with available ID.
261        let id1 = catalog.generate_and_register_unique_id(
262            "available",
263            Some("Available Ref"),
264            RefType::Anchor,
265        );
266        assert_eq!(id1, "available");
267        assert!(catalog.contains_id("available"));
268        assert_eq!(
269            catalog.resolve_id("Available Ref"),
270            Some("available".to_string())
271        );
272
273        // Test with taken IDs.
274        catalog
275            .register_ref("taken", None, RefType::Anchor)
276            .unwrap();
277        catalog
278            .register_ref("taken-2", None, RefType::Anchor)
279            .unwrap();
280
281        let id2 = catalog.generate_and_register_unique_id("taken", None, RefType::Section);
282        assert_eq!(id2, "taken-3");
283        assert!(catalog.contains_id("taken-3"));
284    }
285
286    #[test]
287    fn get_ref() {
288        let mut catalog = Catalog::new();
289
290        catalog
291            .register_ref("test-id", Some("Test Reference"), RefType::Bibliography)
292            .unwrap();
293
294        let entry = catalog.get_ref("test-id").unwrap();
295        assert_eq!(entry.id, "test-id");
296        assert_eq!(entry.reftext, Some("Test Reference".to_string()));
297        assert_eq!(entry.ref_type, RefType::Bibliography);
298
299        assert!(catalog.get_ref("nonexistent").is_none());
300    }
301
302    #[test]
303    fn resolve_id() {
304        let mut catalog = Catalog::new();
305
306        catalog
307            .register_ref("anchor1", Some("Reference Text"), RefType::Anchor)
308            .unwrap();
309
310        catalog
311            .register_ref("anchor2", Some("Another Reference"), RefType::Section)
312            .unwrap();
313
314        assert_eq!(
315            catalog.resolve_id("Reference Text"),
316            Some("anchor1".to_string())
317        );
318        assert_eq!(
319            catalog.resolve_id("Another Reference"),
320            Some("anchor2".to_string())
321        );
322        assert_eq!(catalog.resolve_id("Nonexistent"), None);
323    }
324
325    #[test]
326    fn resolve_id_first_wins_on_duplicates() {
327        let mut catalog = Catalog::new();
328
329        // Register two different IDs with same reftext.
330        catalog
331            .register_ref("first", Some("Same Text"), RefType::Anchor)
332            .unwrap();
333
334        catalog
335            .register_ref("second", Some("Same Text"), RefType::Section)
336            .unwrap();
337
338        assert_eq!(catalog.resolve_id("Same Text"), Some("first".to_string()));
339    }
340
341    #[test]
342    fn duplicate_id_error_impl_display() {
343        let did_error = DuplicateIdError("foo".to_string());
344        assert_eq!(did_error.to_string(), "ID 'foo' already registered");
345    }
346
347    #[test]
348    fn ref_type_impl_debug() {
349        assert_eq!(format!("{:#?}", RefType::Anchor), "RefType::Anchor");
350        assert_eq!(format!("{:#?}", RefType::Section), "RefType::Section");
351
352        assert_eq!(
353            format!("{:#?}", RefType::Bibliography),
354            "RefType::Bibliography"
355        );
356    }
357}