use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::Artifact;
#[cfg(feature = "synapse")]
pub mod synapse;
pub const CATALOG_ENV: &str = "DSPK_HUB_CATALOG";
pub const CATALOG_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreditPaper {
pub title: String,
pub authors: Vec<String>,
pub venue: String,
pub year: u32,
pub doi: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreditCode {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default)]
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credit {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paper: Option<CreditPaper>,
pub code: CreditCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_license: Option<String>,
#[serde(default)]
pub notes: String,
}
impl Credit {
pub fn citation(&self) -> String {
let code = match &self.code.license {
Some(l) => format!("Code: {} ({}, {l})", self.code.url, self.code.version),
None => format!("Code: {} ({})", self.code.url, self.code.version),
};
match &self.paper {
Some(p) => {
let authors = match p.authors.as_slice() {
[] => String::new(),
[one] => one.clone(),
[first, second] => format!("{first} & {second}"),
[first, .., last] => format!("{first} … {last}"),
};
format!("{authors} ({}). {}. {}. https://doi.org/{}. {code}", p.year, p.title, p.venue, p.doi)
}
None => code,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CatalogEntry {
pub id: String,
pub name: String,
pub family: String,
pub kind: String,
#[serde(default)]
pub description: String,
pub file_name: String,
pub url: String,
pub sha256: String,
pub size_bytes: u64,
pub credit: Credit,
}
impl CatalogEntry {
pub fn artifact(&self) -> Artifact {
Artifact {
id: self.id.clone(),
family: self.family.clone(),
file_name: self.file_name.clone(),
url: self.url.clone(),
sha256: self.sha256.clone(),
size_bytes: self.size_bytes,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CatalogDocument {
schema_version: u32,
artifacts: Vec<CatalogEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct Catalog {
entries: Vec<CatalogEntry>,
}
impl Catalog {
pub fn load_default() -> Result<Self> {
let mut catalog = Self::default();
#[cfg(feature = "synapse")]
catalog.merge(Self::from_json_str(synapse::SYNAPSE_CATALOG_JSON).context("built-in synapse catalog")?);
if let Some(path) = std::env::var_os(CATALOG_ENV).filter(|p| !p.is_empty()) {
catalog.merge_file(Path::new(&path))?;
}
Ok(catalog)
}
pub fn from_json_str(json: &str) -> Result<Self> {
let doc: CatalogDocument = serde_json::from_str(json).context("invalid catalog JSON")?;
anyhow::ensure!(
doc.schema_version == CATALOG_SCHEMA_VERSION,
"catalog schema_version {} is not supported (this dspk-hub reads {CATALOG_SCHEMA_VERSION})",
doc.schema_version
);
Ok(Self { entries: doc.artifacts })
}
pub fn merge_file(&mut self, path: &Path) -> Result<()> {
let text = std::fs::read_to_string(path).with_context(|| format!("catalog {}", path.display()))?;
self.merge(Self::from_json_str(&text).with_context(|| format!("catalog {}", path.display()))?);
Ok(())
}
pub fn merge(&mut self, other: Catalog) {
for incoming in other.entries {
match self.entries.iter_mut().find(|e| e.id == incoming.id) {
Some(existing) => *existing = incoming,
None => self.entries.push(incoming),
}
}
}
pub fn entries(&self) -> &[CatalogEntry] {
&self.entries
}
pub fn get(&self, id: &str) -> Option<&CatalogEntry> {
self.entries.iter().find(|e| e.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_replaces_by_id_and_appends_new_ids() {
let doc = |id: &str, url: &str| {
format!(
r#"{{"schema_version": 2, "artifacts": [{{"id": "{id}", "name": "n", "family": "f", "kind": "k", "file_name": "a.bin",
"url": "{url}", "sha256": "{}", "size_bytes": 1, "credit": {{"code": {{"url": "https://example.org"}}}}}}]}}"#,
"0".repeat(64)
)
};
let mut catalog = Catalog::from_json_str(&doc("f/a", "https://one")).unwrap();
catalog.merge(Catalog::from_json_str(&doc("f/a", "https://two")).unwrap());
catalog.merge(Catalog::from_json_str(&doc("f/b", "https://three")).unwrap());
assert_eq!(catalog.entries().len(), 2);
assert_eq!(catalog.get("f/a").unwrap().url, "https://two");
assert!(Catalog::from_json_str(r#"{"schema_version": 1, "artifacts": []}"#).is_err(), "other schema versions are refused");
}
}