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