use std::fs;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use thiserror::Error;
pub mod biblatex;
pub mod formats;
pub use citum_schema::InputBibliography;
pub use citum_schema::reference::InputReference as Reference;
pub type Bibliography = IndexMap<String, Reference>;
#[derive(Error, Debug)]
pub enum RefsError {
#[error("File I/O error: {0}")]
FileIO(#[from] std::io::Error),
#[error("Parse error ({0}): {1}")]
ParseError(String, String),
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RefsFormat {
CitumYaml,
CitumJson,
CitumCbor,
CslJson,
Biblatex,
Ris,
}
#[derive(Debug, Clone, Default)]
pub struct LoadedRefs {
pub references: Bibliography,
pub sets: Option<IndexMap<String, Vec<String>>>,
}
pub fn validate_compound_sets(
sets: Option<IndexMap<String, Vec<String>>>,
bibliography: &Bibliography,
) -> Result<Option<IndexMap<String, Vec<String>>>, RefsError> {
let sets = match sets {
Some(s) if !s.is_empty() => s,
_ => return Ok(None),
};
let mut membership: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for (set_id, members) in &sets {
let mut seen_in_set = std::collections::HashSet::new();
for member_id in members {
if !bibliography.contains_key(member_id.as_str()) {
return Err(RefsError::ParseError(
"BIBLIOGRAPHY".to_string(),
format!("Compound set '{set_id}' contains unknown id '{member_id}'"),
));
}
if !seen_in_set.insert(member_id.as_str()) {
return Err(RefsError::ParseError(
"BIBLIOGRAPHY".to_string(),
format!(
"Reference '{member_id}' appears more than once in compound set '{set_id}'"
),
));
}
if let Some(existing_set) = membership.insert(member_id.as_str(), set_id.as_str()) {
return Err(RefsError::ParseError(
"BIBLIOGRAPHY".to_string(),
format!(
"Reference '{member_id}' appears in both compound sets '{existing_set}' and '{set_id}'"
),
));
}
}
}
Ok(Some(sets))
}
pub fn load_refs_with_sets(path: &Path) -> Result<LoadedRefs, RefsError> {
let bytes = fs::read(path)?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
match ext {
"cbor" => formats::native::parse_cbor_refs(&bytes),
"json" => formats::native::parse_json_refs(&bytes),
_ => {
let content = String::from_utf8_lossy(&bytes);
formats::native::parse_yaml_refs(&content)
}
}
}
pub fn load_refs(path: &Path) -> Result<Bibliography, RefsError> {
Ok(load_refs_with_sets(path)?.references)
}
pub fn load_merged_refs(paths: &[PathBuf]) -> Result<LoadedRefs, RefsError> {
if paths.is_empty() {
return Err(RefsError::ParseError(
"BIBLIOGRAPHY".to_string(),
"At least one bibliography path is required.".to_string(),
));
}
let mut merged = Bibliography::new();
let mut merged_sets = IndexMap::<String, Vec<String>>::new();
for path in paths {
let loaded = load_refs_with_sets(path)?;
for (id, reference) in loaded.references {
merged.insert(id, reference);
}
if let Some(sets) = loaded.sets {
for (set_id, members) in sets {
if merged_sets.contains_key(&set_id) {
return Err(RefsError::ParseError(
"BIBLIOGRAPHY".to_string(),
format!("Duplicate compound set id while merging: {set_id}"),
));
}
merged_sets.insert(set_id, members);
}
}
}
let validated_sets =
validate_compound_sets((!merged_sets.is_empty()).then_some(merged_sets), &merged)?;
Ok(LoadedRefs {
references: merged,
sets: validated_sets,
})
}
pub fn load_input_refs(path: &Path, format: RefsFormat) -> Result<InputBibliography, RefsError> {
match format {
RefsFormat::CitumYaml => {
let bytes = fs::read(path)?;
formats::native::deserialize_any(&bytes, "yaml")
}
RefsFormat::CitumJson => {
let bytes = fs::read(path)?;
formats::native::load_citum_json(&bytes)
}
RefsFormat::CitumCbor => {
let bytes = fs::read(path)?;
formats::native::deserialize_any(&bytes, "cbor")
}
RefsFormat::CslJson => formats::csl_json::load_csl_json(path),
RefsFormat::Biblatex => formats::biblatex::load_biblatex(path),
RefsFormat::Ris => formats::ris::load_ris(path),
}
}
pub fn infer_refs_input_format(path: &Path) -> Result<RefsFormat, RefsError> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let fmt = match ext.to_ascii_lowercase().as_str() {
"yaml" | "yml" => RefsFormat::CitumYaml,
"cbor" => RefsFormat::CitumCbor,
"bib" => RefsFormat::Biblatex,
"ris" => RefsFormat::Ris,
"json" => detect_json_refs_format(path)?,
_ => RefsFormat::CitumYaml,
};
Ok(fmt)
}
#[must_use]
pub fn infer_refs_output_format(path: &Path) -> RefsFormat {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext.to_ascii_lowercase().as_str() {
"yaml" | "yml" => RefsFormat::CitumYaml,
"cbor" => RefsFormat::CitumCbor,
"bib" => RefsFormat::Biblatex,
"ris" => RefsFormat::Ris,
"json" => RefsFormat::CitumJson,
_ => RefsFormat::CitumYaml,
}
}
fn detect_json_refs_format(path: &Path) -> Result<RefsFormat, RefsError> {
let bytes = fs::read(path)?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| RefsError::ParseError("JSON".to_string(), e.to_string()))?;
let array = value.as_array();
let is_citum_array = array.is_some_and(|items| items.iter().any(|v| v.get("class").is_some()));
let is_csl_array = array.is_some_and(|items| {
items.iter().any(|v| {
v.get("id").is_some()
&& v.get("type").is_some()
&& (v.get("title").is_some() || v.get("author").is_some())
})
});
let is_citum_object = value.get("references").is_some();
if is_csl_array && !is_citum_array && !is_citum_object {
Ok(RefsFormat::CslJson)
} else {
Ok(RefsFormat::CitumJson)
}
}