1use 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
28pub type Bibliography = IndexMap<String, Reference>;
34
35#[derive(Error, Debug)]
37pub enum RefsError {
38 #[error("File I/O error: {0}")]
40 FileIO(#[from] std::io::Error),
41
42 #[error("Parse error ({0}): {1}")]
44 ParseError(String, String),
45}
46
47#[derive(Copy, Clone, Debug, PartialEq, Eq)]
49pub enum RefsFormat {
50 CitumYaml,
52 CitumJson,
54 CitumCbor,
56 CslJson,
58 Biblatex,
60 Ris,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct LoadedRefs {
67 pub references: Bibliography,
69 pub sets: Option<IndexMap<String, Vec<String>>>,
71}
72
73pub 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
127pub 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
149pub fn load_refs(path: &Path) -> Result<Bibliography, RefsError> {
158 Ok(load_refs_with_sets(path)?.references)
159}
160
161pub 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
208pub 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
233pub 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#[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}