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