Skip to main content

citum_refs/
lib.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Citum reference data loading and parsing.
7//!
8//! Provides multi-format bibliography parsing (Citum YAML/JSON/CBOR, CSL-JSON,
9//! BibLaTeX, RIS) without depending on `citum-engine`. Both `citum-engine` and
10//! `citum-io` depend on this crate; surface crates (`citum-server`,
11//! `citum-bindings`) may depend on it directly.
12//!
13//! BibLaTeX parsing is provided via [`formats::biblatex::load_biblatex`] and the
14//! conversion helpers in [`biblatex`].
15
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use indexmap::IndexMap;
20use thiserror::Error;
21
22pub mod biblatex;
23pub mod formats;
24
25pub use citum_schema::InputBibliography;
26pub use citum_schema::reference::InputReference as Reference;
27
28// TODO: rename Bibliography → RefsMap (or similar) to disambiguate from the
29// rendered-bibliography concept in citum-schema-style (BibliographyConfig,
30// BibliographyOptions, etc.). This is a workspace-wide rename; deferred to a
31// follow-up PR.
32/// A resolved map of reference records keyed by ID.
33pub type Bibliography = IndexMap<String, Reference>;
34
35/// Errors produced while loading or parsing reference data.
36#[derive(Error, Debug)]
37pub enum RefsError {
38    /// Reading an input file from disk failed.
39    #[error("File I/O error: {0}")]
40    FileIO(#[from] std::io::Error),
41
42    /// Parsing a named input failed with a message describing the problem.
43    #[error("Parse error ({0}): {1}")]
44    ParseError(String, String),
45}
46
47/// Bibliography formats supported by reference loading helpers.
48#[derive(Copy, Clone, Debug, PartialEq, Eq)]
49pub enum RefsFormat {
50    /// Native Citum bibliography encoded as YAML.
51    CitumYaml,
52    /// Native Citum bibliography encoded as JSON.
53    CitumJson,
54    /// Native Citum bibliography encoded as CBOR.
55    CitumCbor,
56    /// Legacy CSL-JSON bibliography.
57    CslJson,
58    /// BibLaTeX `.bib` bibliography.
59    Biblatex,
60    /// RIS bibliography.
61    Ris,
62}
63
64/// Reference data loaded from input, including optional compound sets.
65#[derive(Debug, Clone, Default)]
66pub struct LoadedRefs {
67    /// Parsed references keyed by ID.
68    pub references: Bibliography,
69    /// Optional compound sets keyed by set ID.
70    pub sets: Option<IndexMap<String, Vec<String>>>,
71}
72
73/// Validate compound sets against a bibliography.
74///
75/// Checks that every set member ID exists in the bibliography, that no ID
76/// appears in more than one set, and that no ID appears more than once within
77/// the same set.
78///
79/// Returns `None` when `sets` is `None` or empty; otherwise returns the
80/// validated sets.
81///
82/// # Errors
83///
84/// Returns `RefsError::ParseError` for unknown member IDs or duplicates.
85pub fn validate_compound_sets(
86    sets: Option<IndexMap<String, Vec<String>>>,
87    bibliography: &Bibliography,
88) -> Result<Option<IndexMap<String, Vec<String>>>, RefsError> {
89    let sets = match sets {
90        Some(s) if !s.is_empty() => s,
91        _ => return Ok(None),
92    };
93
94    let mut membership: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
95
96    for (set_id, members) in &sets {
97        let mut seen_in_set = std::collections::HashSet::new();
98        for member_id in members {
99            if !bibliography.contains_key(member_id.as_str()) {
100                return Err(RefsError::ParseError(
101                    "BIBLIOGRAPHY".to_string(),
102                    format!("Compound set '{set_id}' contains unknown id '{member_id}'"),
103                ));
104            }
105            if !seen_in_set.insert(member_id.as_str()) {
106                return Err(RefsError::ParseError(
107                    "BIBLIOGRAPHY".to_string(),
108                    format!(
109                        "Reference '{member_id}' appears more than once in compound set '{set_id}'"
110                    ),
111                ));
112            }
113            if let Some(existing_set) = membership.insert(member_id.as_str(), set_id.as_str()) {
114                return Err(RefsError::ParseError(
115                    "BIBLIOGRAPHY".to_string(),
116                    format!(
117                        "Reference '{member_id}' appears in both compound sets '{existing_set}' and '{set_id}'"
118                    ),
119                ));
120            }
121        }
122    }
123
124    Ok(Some(sets))
125}
126
127/// Load reference data from a file, including optional compound sets.
128///
129/// Supports Citum YAML/JSON/CBOR and CSL-JSON.
130///
131/// # Errors
132///
133/// Returns an error when the file cannot be read, cannot be parsed, or
134/// compound sets are invalid.
135pub fn load_refs_with_sets(path: &Path) -> Result<LoadedRefs, RefsError> {
136    let bytes = fs::read(path)?;
137    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
138
139    match ext {
140        "cbor" => formats::native::parse_cbor_refs(&bytes),
141        "json" => formats::native::parse_json_refs(&bytes),
142        _ => {
143            let content = String::from_utf8_lossy(&bytes);
144            formats::native::parse_yaml_refs(&content)
145        }
146    }
147}
148
149/// Load references from a file.
150///
151/// Supports Citum YAML/JSON/CBOR and CSL-JSON.
152///
153/// # Errors
154///
155/// Returns an error when the file cannot be read, cannot be parsed, or
156/// embedded compound-set metadata is invalid.
157pub fn load_refs(path: &Path) -> Result<Bibliography, RefsError> {
158    Ok(load_refs_with_sets(path)?.references)
159}
160
161/// Load and merge one or more bibliography files, preserving compound set metadata.
162///
163/// Entries from later files replace entries with the same ID from earlier files.
164/// Compound set IDs must be unique across input files, and final membership is
165/// validated against the merged bibliography.
166///
167/// # Errors
168///
169/// Returns an error when no paths are supplied, any file cannot be loaded, or
170/// merged compound sets are invalid.
171pub fn load_merged_refs(paths: &[PathBuf]) -> Result<LoadedRefs, RefsError> {
172    if paths.is_empty() {
173        return Err(RefsError::ParseError(
174            "BIBLIOGRAPHY".to_string(),
175            "At least one bibliography path is required.".to_string(),
176        ));
177    }
178
179    let mut merged = Bibliography::new();
180    let mut merged_sets = IndexMap::<String, Vec<String>>::new();
181    for path in paths {
182        let loaded = load_refs_with_sets(path)?;
183        for (id, reference) in loaded.references {
184            merged.insert(id, reference);
185        }
186        if let Some(sets) = loaded.sets {
187            for (set_id, members) in sets {
188                if merged_sets.contains_key(&set_id) {
189                    return Err(RefsError::ParseError(
190                        "BIBLIOGRAPHY".to_string(),
191                        format!("Duplicate compound set id while merging: {set_id}"),
192                    ));
193                }
194                merged_sets.insert(set_id, members);
195            }
196        }
197    }
198
199    let validated_sets =
200        validate_compound_sets((!merged_sets.is_empty()).then_some(merged_sets), &merged)?;
201
202    Ok(LoadedRefs {
203        references: merged,
204        sets: validated_sets,
205    })
206}
207
208/// Load bibliography input in a specified native or legacy reference format.
209///
210/// # Errors
211///
212/// Returns an error when the file cannot be read or parsed as `format`.
213pub fn load_input_refs(path: &Path, format: RefsFormat) -> Result<InputBibliography, RefsError> {
214    match format {
215        RefsFormat::CitumYaml => {
216            let bytes = fs::read(path)?;
217            formats::native::deserialize_any(&bytes, "yaml")
218        }
219        RefsFormat::CitumJson => {
220            let bytes = fs::read(path)?;
221            formats::native::load_citum_json(&bytes)
222        }
223        RefsFormat::CitumCbor => {
224            let bytes = fs::read(path)?;
225            formats::native::deserialize_any(&bytes, "cbor")
226        }
227        RefsFormat::CslJson => formats::csl_json::load_csl_json(path),
228        RefsFormat::Biblatex => formats::biblatex::load_biblatex(path),
229        RefsFormat::Ris => formats::ris::load_ris(path),
230    }
231}
232
233/// Infer a bibliography input format from a path.
234///
235/// JSON inputs are content-sniffed to distinguish native Citum JSON from CSL-JSON.
236///
237/// # Errors
238///
239/// Returns an error when a JSON input cannot be read or parsed for detection.
240pub fn infer_refs_input_format(path: &Path) -> Result<RefsFormat, RefsError> {
241    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
242    let fmt = match ext.to_ascii_lowercase().as_str() {
243        "yaml" | "yml" => RefsFormat::CitumYaml,
244        "cbor" => RefsFormat::CitumCbor,
245        "bib" => RefsFormat::Biblatex,
246        "ris" => RefsFormat::Ris,
247        "json" => detect_json_refs_format(path)?,
248        _ => RefsFormat::CitumYaml,
249    };
250    Ok(fmt)
251}
252
253/// Infer a bibliography output format from a path.
254///
255/// Unknown extensions default to native Citum YAML.
256#[must_use]
257pub fn infer_refs_output_format(path: &Path) -> RefsFormat {
258    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
259    match ext.to_ascii_lowercase().as_str() {
260        "yaml" | "yml" => RefsFormat::CitumYaml,
261        "cbor" => RefsFormat::CitumCbor,
262        "bib" => RefsFormat::Biblatex,
263        "ris" => RefsFormat::Ris,
264        "json" => RefsFormat::CitumJson,
265        _ => RefsFormat::CitumYaml,
266    }
267}
268
269fn detect_json_refs_format(path: &Path) -> Result<RefsFormat, RefsError> {
270    let bytes = fs::read(path)?;
271    let value: serde_json::Value = serde_json::from_slice(&bytes)
272        .map_err(|e| RefsError::ParseError("JSON".to_string(), e.to_string()))?;
273    let array = value.as_array();
274    let is_citum_array = array.is_some_and(|items| items.iter().any(|v| v.get("class").is_some()));
275    let is_csl_array = array.is_some_and(|items| {
276        items.iter().any(|v| {
277            v.get("id").is_some()
278                && v.get("type").is_some()
279                && (v.get("title").is_some() || v.get("author").is_some())
280        })
281    });
282    let is_citum_object = value.get("references").is_some();
283    if is_csl_array && !is_citum_array && !is_citum_object {
284        Ok(RefsFormat::CslJson)
285    } else {
286        Ok(RefsFormat::CitumJson)
287    }
288}