1use 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
29pub type Bibliography = IndexMap<String, Reference>;
35
36#[derive(Error, Debug)]
38pub enum RefsError {
39 #[error("File I/O error: {0}")]
41 FileIO(#[from] std::io::Error),
42
43 #[error("Parse error ({0}): {1}")]
45 ParseError(String, String),
46}
47
48#[derive(Copy, Clone, Debug, PartialEq, Eq)]
50pub enum RefsFormat {
51 CitumYaml,
53 CitumJson,
55 CitumCbor,
57 CslJson,
59 Biblatex,
61 Ris,
63}
64
65#[derive(Debug, Clone, Default)]
67pub struct LoadedRefs {
68 pub references: Bibliography,
70 pub sets: Option<IndexMap<String, Vec<String>>>,
72}
73
74pub 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
128pub 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
150pub fn load_refs(path: &Path) -> Result<Bibliography, RefsError> {
159 Ok(load_refs_with_sets(path)?.references)
160}
161
162pub 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
209pub 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
234pub 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#[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}