use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use url::Url;
use crate::ids::{BucketUrl, ConceptRecId, DepositionFileId, DepositionId, Doi, RecordId};
use crate::metadata::RecordMetadata;
macro_rules! string_enum {
($(#[$enum_meta:meta])* $name:ident { $($(#[$variant_meta:meta])* $variant:ident => $value:literal),+ $(,)? }) => {
$(#[$enum_meta])*
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum $name {
$($(#[$variant_meta])* $variant,)+
Unknown(
String
),
}
impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(match self {
$(Self::$variant => $value,)+
Self::Unknown(value) => value.as_str(),
})
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
$($value => Self::$variant,)+
_ => Self::Unknown(value),
})
}
}
};
}
string_enum!(
#[derive(Default)]
DepositState {
#[default]
InProgress => "inprogress",
Done => "done",
Error => "error"
}
);
string_enum!(
#[derive(Default)]
RecordPublicationStatus {
#[default]
Published => "published",
Draft => "draft"
}
);
fn deserialize_stringish<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Stringish {
String(String),
Number(u64),
}
match Stringish::deserialize(deserializer)? {
Stringish::String(value) => Ok(value),
Stringish::Number(value) => Ok(value.to_string()),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DepositionStatus {
#[serde(default)]
pub submitted: bool,
#[serde(default)]
pub state: DepositState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DepositionFile {
pub id: DepositionFileId,
pub filename: String,
#[serde(default)]
pub filesize: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DepositionLinks {
#[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
pub self_: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<BucketUrl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discard: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_draft: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub versions: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Deposition {
pub id: DepositionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptrecid: Option<ConceptRecId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub record_id: Option<RecordId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<Doi>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptdoi: Option<Doi>,
#[serde(flatten)]
pub status: DepositionStatus,
#[serde(default)]
pub metadata: Value,
#[serde(default)]
pub files: Vec<DepositionFile>,
#[serde(default)]
pub links: DepositionLinks,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl Deposition {
#[must_use]
pub fn is_published(&self) -> bool {
self.status.submitted
}
#[must_use]
pub fn latest_draft_url(&self) -> Option<&Url> {
self.links.latest_draft.as_ref()
}
#[must_use]
pub fn bucket_url(&self) -> Option<&BucketUrl> {
self.links.bucket.as_ref()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BucketObject {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub key: String,
#[serde(default)]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordFileLinks {
#[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
pub self_: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordFile {
pub id: String,
pub key: String,
#[serde(default)]
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
#[serde(default)]
pub links: RecordFileLinks,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl RecordFile {
#[must_use]
pub fn download_url(&self) -> Option<&Url> {
self.links.content.as_ref().or(self.links.self_.as_ref())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordLinks {
#[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
pub self_: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub self_html: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub versions: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<Url>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordStats {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub views: Option<u64>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PersistentIdentifier {
pub identifier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordPids {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<PersistentIdentifier>,
#[serde(
rename = "concept-doi",
default,
skip_serializing_if = "Option::is_none"
)]
pub concept_doi: Option<PersistentIdentifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recid: Option<PersistentIdentifier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oai: Option<PersistentIdentifier>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordParentCommunities {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default)]
pub ids: Vec<String>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RecordParent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub communities: Option<RecordParentCommunities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids: Option<RecordPids>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "updated")]
pub modified: Option<DateTime<Utc>>,
pub id: RecordId,
#[serde(deserialize_with = "deserialize_stringish")]
pub recid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptrecid: Option<ConceptRecId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<Doi>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conceptdoi: Option<Doi>,
#[serde(default)]
pub metadata: RecordMetadata,
#[serde(default)]
pub files: Vec<RecordFile>,
#[serde(default)]
pub links: RecordLinks,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<RecordParent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids: Option<RecordPids>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<RecordStats>,
#[serde(default)]
pub status: RecordPublicationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
#[serde(flatten, default)]
pub extra: BTreeMap<String, Value>,
}
impl Record {
#[must_use]
pub fn latest_url(&self) -> Option<&Url> {
self.links.latest.as_ref()
}
#[must_use]
pub fn archive_url(&self) -> Option<&Url> {
self.links.archive.as_ref()
}
#[must_use]
pub fn file_by_key(&self, key: &str) -> Option<&RecordFile> {
self.files.iter().find(|file| file.key == key)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactInfo {
pub record: Record,
pub latest: Record,
pub files_by_key: BTreeMap<String, RecordFile>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishedRecord {
pub deposition: Deposition,
pub record: Record,
}
#[cfg(test)]
mod tests {
use super::{
DepositState, Deposition, Record, RecordFile, RecordLinks, RecordPublicationStatus,
};
#[test]
fn preserves_unknown_record_fields() {
let record: Record = serde_json::from_value(serde_json::json!({
"created": "2026-04-03T12:00:00+00:00",
"updated": "2026-04-03T13:00:00+00:00",
"id": 42,
"recid": "42",
"metadata": { "title": "artifact" },
"files": [],
"links": {},
"parent": {
"id": "parent-1",
"communities": {
"default": "zenodo",
"ids": ["zenodo", "sandbox"]
},
"pids": {
"doi": {
"identifier": "10.5281/zenodo.42"
}
}
},
"pids": {
"doi": {
"identifier": "10.5281/zenodo.42"
},
"concept-doi": {
"identifier": "10.5281/zenodo.41"
}
},
"mystery": "value"
}))
.unwrap();
assert!(record.created.is_some());
assert!(record.modified.is_some());
assert_eq!(
record
.parent
.as_ref()
.and_then(|parent| parent.id.as_deref()),
Some("parent-1")
);
assert_eq!(
record
.pids
.as_ref()
.and_then(|pids| pids.doi.as_ref())
.map(|pid| pid.identifier.as_str()),
Some("10.5281/zenodo.42")
);
assert_eq!(
record.extra.get("mystery"),
Some(&serde_json::Value::String("value".into()))
);
}
#[test]
fn follows_latest_draft_link() {
let deposition: Deposition = serde_json::from_value(serde_json::json!({
"id": 7,
"submitted": true,
"state": "done",
"metadata": {},
"files": [],
"links": {
"latest_draft": "https://zenodo.org/api/deposit/depositions/8"
}
}))
.unwrap();
assert_eq!(
deposition.latest_draft_url().unwrap().as_str(),
"https://zenodo.org/api/deposit/depositions/8"
);
}
#[test]
fn record_file_prefers_content_link() {
let file: RecordFile = serde_json::from_value(serde_json::json!({
"id": "f1",
"key": "artifact.bin",
"links": {
"self": "https://zenodo.org/api/files/self",
"content": "https://zenodo.org/api/files/content"
}
}))
.unwrap();
assert_eq!(
file.download_url().unwrap().as_str(),
"https://zenodo.org/api/files/content"
);
}
#[test]
fn record_and_deposition_helpers_expose_status_and_links() {
let deposition: Deposition = serde_json::from_value(serde_json::json!({
"id": 9,
"submitted": false,
"state": "mystery-state",
"metadata": {},
"files": [],
"links": {
"bucket": "https://zenodo.org/api/files/bucket-9"
}
}))
.unwrap();
let record = Record {
created: None,
modified: None,
id: crate::RecordId(10),
recid: "10".into(),
conceptrecid: None,
doi: None,
conceptdoi: None,
metadata: crate::RecordMetadata::default(),
files: Vec::new(),
links: RecordLinks::default(),
parent: None,
pids: None,
stats: None,
status: RecordPublicationStatus::Draft,
revision: None,
extra: std::collections::BTreeMap::new(),
};
assert!(!deposition.is_published());
assert!(deposition.bucket_url().is_some());
assert!(matches!(deposition.status.state, DepositState::Unknown(_)));
assert!(record.latest_url().is_none());
assert!(record.archive_url().is_none());
}
}