Skip to main content

citum_schema_style/
registry.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Style registry — discovery and alias resolution for citation styles.
7
8#[cfg(feature = "schema")]
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12use std::sync::OnceLock;
13
14static DEFAULT_REGISTRY: OnceLock<StyleRegistry> = OnceLock::new();
15
16/// Tier classification for a style in the registry.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schema", derive(JsonSchema))]
19#[serde(rename_all = "kebab-case")]
20pub enum StyleKind {
21    /// Complete style that serves as an inheritance root.
22    Base,
23    /// Organizational adaptation of a base style (publisher, society, standards body).
24    Profile,
25    /// Pure alias pointing to a profile or base style.
26    Journal,
27    /// Standalone style with no aliases and no inheritance role.
28    Independent,
29}
30
31/// A single entry in a style registry.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(JsonSchema))]
34pub struct RegistryEntry {
35    /// Canonical style ID, must match the key used in `get_embedded_style`.
36    pub id: String,
37    /// Short aliases that resolve to this entry (default empty).
38    #[serde(default)]
39    pub aliases: Vec<String>,
40    /// Name of an embedded style (present for the default registry).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub builtin: Option<String>,
43    /// Relative path to a YAML file (used in local registries).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub path: Option<PathBuf>,
46    /// HTTP URL to a YAML style file.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub url: Option<String>,
49    /// Human-readable title from the style metadata.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub title: Option<String>,
52    /// Human-readable description.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub description: Option<String>,
55    /// Subject/domain classification tags (default empty).
56    #[serde(default)]
57    pub fields: Vec<String>,
58    /// Tier classification (base, profile, journal, independent).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub kind: Option<StyleKind>,
61}
62
63/// A registry of citation styles with alias resolution.
64#[derive(Clone, Debug, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(JsonSchema))]
66pub struct StyleRegistry {
67    /// Version identifier for the registry format.
68    pub version: String,
69    /// List of style entries in the registry.
70    pub styles: Vec<RegistryEntry>,
71}
72
73impl StyleRegistry {
74    /// Resolve a name or alias to the matching registry entry.
75    ///
76    /// Checks `id` first, then searches aliases.
77    pub fn resolve(&self, name: &str) -> Option<&RegistryEntry> {
78        // Check exact ID match
79        if let Some(entry) = self.styles.iter().find(|e| e.id == name) {
80            return Some(entry);
81        }
82        // Check aliases
83        self.styles
84            .iter()
85            .find(|e| e.aliases.iter().any(|a| a == name))
86    }
87
88    /// All canonical style IDs in the registry.
89    pub fn all_ids(&self) -> impl Iterator<Item = &str> {
90        self.styles.iter().map(|e| e.id.as_str())
91    }
92
93    /// Merge another registry over self (self wins on ID conflict).
94    ///
95    /// Entries from `base` are included first. If an entry in `self`
96    /// has the same ID as one in `base`, the entry from `self` replaces it.
97    /// New entries from `self` are appended.
98    #[must_use]
99    #[allow(clippy::indexing_slicing, reason = "pos is found via .position()")]
100    pub fn merge_over(&self, base: &StyleRegistry) -> StyleRegistry {
101        let mut result = base.clone();
102        for entry in &self.styles {
103            if let Some(pos) = result.styles.iter().position(|e| e.id == entry.id) {
104                result.styles[pos] = entry.clone();
105            } else {
106                result.styles.push(entry.clone());
107            }
108        }
109        result
110    }
111
112    /// Build a registry from embedded style name and alias slices.
113    ///
114    /// Used to construct the default registry from hardcoded embedded data.
115    pub fn from_slices(names: &[&str], aliases: &[(&str, &str)]) -> Self {
116        let mut styles = Vec::new();
117
118        // Create entries for each embedded style name.
119        for name in names {
120            let style_aliases: Vec<String> = aliases
121                .iter()
122                .filter(|(_, full)| full == name)
123                .map(|(alias, _)| (*alias).to_string())
124                .collect();
125
126            styles.push(RegistryEntry {
127                id: (*name).to_string(),
128                aliases: style_aliases,
129                builtin: Some((*name).to_string()),
130                path: None,
131                url: None,
132                title: None,
133                description: None,
134                fields: Vec::new(),
135                kind: None,
136            });
137        }
138
139        StyleRegistry {
140            version: "1".to_string(),
141            styles,
142        }
143    }
144
145    /// Load the embedded default registry from the compiled-in YAML data.
146    ///
147    /// # Panics
148    /// Panics only if the embedded YAML is malformed (should never happen in
149    /// a correctly built binary).
150    #[allow(
151        clippy::expect_used,
152        clippy::panic,
153        reason = "Embedded registry must be valid at runtime"
154    )]
155    pub fn load_default() -> Self {
156        DEFAULT_REGISTRY
157            .get_or_init(|| {
158                let bytes = include_bytes!("../embedded/registry/default.yaml");
159                let registry: Self = serde_yaml::from_slice(bytes)
160                    .expect("embedded registry/default.yaml is valid YAML");
161                registry
162                    .validate_sources()
163                    .expect("embedded registry/default.yaml has valid style sources");
164                for entry in &registry.styles {
165                    if entry.kind == Some(StyleKind::Profile)
166                        && let Some(name) = &entry.builtin
167                        && let Some(style) = crate::embedded::get_embedded_style(name)
168                    {
169                        let style = style.expect("embedded profile style should parse");
170                        style.validate_profile_shape().unwrap_or_else(|err| {
171                            panic!("embedded profile `{name}` violates profile contract: {err}")
172                        });
173                    }
174                }
175                registry
176            })
177            .clone()
178    }
179
180    /// Load a registry from a YAML file on disk.
181    ///
182    /// # Errors
183    /// Returns an error if the file cannot be read or if the YAML cannot be parsed.
184    /// Also returns an error if any entry does not have exactly one of
185    /// `builtin`, `path`, or `url`.
186    pub fn load_from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
187        let content = std::fs::read(path)?;
188        let registry: Self = serde_yaml::from_slice(&content)?;
189        registry.validate_sources()?;
190        Ok(registry)
191    }
192
193    /// Validate that each entry declares exactly one loadable style source.
194    ///
195    /// # Errors
196    /// Returns an error if any entry has no source or multiple sources.
197    pub fn validate_sources(&self) -> Result<(), Box<dyn std::error::Error>> {
198        for entry in &self.styles {
199            let source_count = usize::from(entry.builtin.is_some())
200                + usize::from(entry.path.is_some())
201                + usize::from(entry.url.is_some());
202            if source_count != 1 {
203                return Err(format!(
204                    "Registry entry '{}' must have exactly one of 'builtin', 'path', or 'url'",
205                    entry.id
206                )
207                .into());
208            }
209        }
210        Ok(())
211    }
212}
213
214#[cfg(test)]
215#[allow(
216    clippy::unwrap_used,
217    clippy::expect_used,
218    clippy::panic,
219    clippy::indexing_slicing,
220    clippy::todo,
221    clippy::unimplemented,
222    clippy::unreachable,
223    clippy::get_unwrap,
224    reason = "Panicking is acceptable and often desired in tests."
225)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_resolve_exact_id() {
231        let registry = StyleRegistry {
232            version: "1".to_string(),
233            styles: vec![RegistryEntry {
234                id: "apa-7th".to_string(),
235                aliases: vec!["apa".to_string()],
236                builtin: Some("apa-7th".to_string()),
237                path: None,
238                url: None,
239                title: None,
240                description: Some("APA 7th edition".to_string()),
241                fields: vec!["psychology".to_string()],
242                kind: None,
243            }],
244        };
245
246        assert!(registry.resolve("apa-7th").is_some());
247        assert_eq!(registry.resolve("apa-7th").unwrap().id, "apa-7th");
248    }
249
250    #[test]
251    fn test_resolve_alias() {
252        let registry = StyleRegistry {
253            version: "1".to_string(),
254            styles: vec![RegistryEntry {
255                id: "apa-7th".to_string(),
256                aliases: vec!["apa".to_string()],
257                builtin: Some("apa-7th".to_string()),
258                path: None,
259                url: None,
260                title: None,
261                description: Some("APA 7th edition".to_string()),
262                fields: vec!["psychology".to_string()],
263                kind: None,
264            }],
265        };
266
267        assert!(registry.resolve("apa").is_some());
268        assert_eq!(registry.resolve("apa").unwrap().id, "apa-7th");
269    }
270
271    #[test]
272    fn test_all_ids() {
273        let registry = StyleRegistry {
274            version: "1".to_string(),
275            styles: vec![
276                RegistryEntry {
277                    id: "apa-7th".to_string(),
278                    aliases: vec!["apa".to_string()],
279                    builtin: Some("apa-7th".to_string()),
280                    path: None,
281                    url: None,
282                    title: None,
283                    description: None,
284                    fields: vec![],
285                    kind: None,
286                },
287                RegistryEntry {
288                    id: "mla".to_string(),
289                    aliases: vec![],
290                    builtin: Some("mla".to_string()),
291                    path: None,
292                    url: None,
293                    title: None,
294                    description: None,
295                    fields: vec![],
296                    kind: None,
297                },
298            ],
299        };
300
301        let ids: Vec<_> = registry.all_ids().collect();
302        assert_eq!(ids, vec!["apa-7th", "mla"]);
303    }
304
305    #[test]
306    fn test_merge_over() {
307        let base = StyleRegistry {
308            version: "1".to_string(),
309            styles: vec![RegistryEntry {
310                id: "apa-7th".to_string(),
311                aliases: vec!["apa".to_string()],
312                builtin: Some("apa-7th".to_string()),
313                path: None,
314                url: None,
315                title: None,
316                description: Some("APA 7th edition".to_string()),
317                fields: vec!["psychology".to_string()],
318                kind: None,
319            }],
320        };
321
322        let custom = StyleRegistry {
323            version: "1".to_string(),
324            styles: vec![
325                RegistryEntry {
326                    id: "custom-style".to_string(),
327                    aliases: vec!["custom".to_string()],
328                    path: Some(PathBuf::from("custom.yaml")),
329                    builtin: None,
330                    url: None,
331                    title: None,
332                    description: Some("Custom style".to_string()),
333                    fields: vec![],
334                    kind: None,
335                },
336                RegistryEntry {
337                    id: "apa-7th".to_string(),
338                    aliases: vec!["apa".to_string()],
339                    builtin: Some("apa-7th".to_string()),
340                    path: None,
341                    url: None,
342                    title: None,
343                    description: Some("APA 7th edition (modified)".to_string()),
344                    fields: vec!["psychology".to_string(), "custom".to_string()],
345                    kind: None,
346                },
347            ],
348        };
349
350        let merged = custom.merge_over(&base);
351
352        assert_eq!(merged.styles.len(), 2);
353        assert!(merged.resolve("custom").is_some());
354        assert_eq!(
355            merged.resolve("apa-7th").unwrap().description,
356            Some("APA 7th edition (modified)".to_string())
357        );
358    }
359
360    #[test]
361    fn test_from_slices() {
362        let names = &["apa-7th", "mla"];
363        let aliases = &[("apa", "apa-7th"), ("mla", "mla")];
364
365        let registry = StyleRegistry::from_slices(names, aliases);
366
367        assert_eq!(registry.styles.len(), 2);
368        assert_eq!(registry.resolve("apa").unwrap().id, "apa-7th");
369        assert_eq!(registry.resolve("mla").unwrap().id, "mla");
370    }
371
372    #[test]
373    fn test_load_default_keeps_profiles_valid() {
374        let registry = StyleRegistry::load_default();
375        let entry = registry
376            .resolve("elsevier-harvard")
377            .expect("elsevier-harvard should exist");
378        assert_eq!(entry.kind, Some(StyleKind::Profile));
379    }
380
381    #[test]
382    fn test_load_default_contains_embedded_and_core_http_entries() {
383        let registry = StyleRegistry::load_default();
384        let embedded = registry.resolve("apa-7th").expect("apa-7th should exist");
385        assert_eq!(embedded.builtin.as_deref(), Some("apa-7th"));
386
387        let core_http = registry.resolve("alpha").expect("alpha should exist");
388        assert_eq!(
389            core_http.url.as_deref(),
390            Some("https://raw.githubusercontent.com/citum/citum-core/main/styles/alpha.yaml")
391        );
392    }
393}