1#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schema", derive(JsonSchema))]
19#[serde(rename_all = "kebab-case")]
20pub enum StyleKind {
21 Base,
23 Profile,
25 Journal,
27 Independent,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(JsonSchema))]
34pub struct RegistryEntry {
35 pub id: String,
37 #[serde(default)]
39 pub aliases: Vec<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub builtin: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub path: Option<PathBuf>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub url: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub title: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub description: Option<String>,
55 #[serde(default)]
57 pub fields: Vec<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub kind: Option<StyleKind>,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(JsonSchema))]
66pub struct StyleRegistry {
67 pub version: String,
69 pub styles: Vec<RegistryEntry>,
71}
72
73impl StyleRegistry {
74 pub fn resolve(&self, name: &str) -> Option<&RegistryEntry> {
78 if let Some(entry) = self.styles.iter().find(|e| e.id == name) {
80 return Some(entry);
81 }
82 self.styles
84 .iter()
85 .find(|e| e.aliases.iter().any(|a| a == name))
86 }
87
88 pub fn all_ids(&self) -> impl Iterator<Item = &str> {
90 self.styles.iter().map(|e| e.id.as_str())
91 }
92
93 #[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 pub fn from_slices(names: &[&str], aliases: &[(&str, &str)]) -> Self {
116 let mut styles = Vec::new();
117
118 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 #[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 ®istry.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 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 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}