Skip to main content

zenodo_rs/
model.rs

1//! Core data models for depositions, records, and files.
2//!
3//! These types are primarily returned by the client rather than constructed by
4//! callers. They represent the stable, typed parts of Zenodo payloads while
5//! still preserving unknown fields for forward compatibility.
6//!
7//! In practice, most callers touch:
8//!
9//! - [`Deposition`] for draft and publish state
10//! - [`Record`] for published metadata and links
11//! - [`RecordFile`] for downloadable files
12//! - [`ArtifactInfo`] and [`PublishedRecord`] for higher-level summaries
13
14use std::collections::BTreeMap;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use serde_json::Value;
19use url::Url;
20
21use crate::ids::{BucketUrl, ConceptRecId, DepositionFileId, DepositionId, Doi, RecordId};
22use crate::metadata::RecordMetadata;
23use crate::serde_util::{deserialize_option_u64ish, deserialize_stringish, deserialize_u64ish};
24
25macro_rules! string_enum {
26    ($(#[$enum_meta:meta])* $name:ident { $($(#[$variant_meta:meta])* $variant:ident => $value:literal),+ $(,)? }) => {
27        $(#[$enum_meta])*
28        #[derive(Clone, Debug, PartialEq, Eq)]
29        #[non_exhaustive]
30        pub enum $name {
31            $($(#[$variant_meta])* $variant,)+
32            /// A server value unknown to this crate version.
33            Unknown(
34                /// Raw server value.
35                String
36            ),
37        }
38
39        impl Serialize for $name {
40            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
41            where
42                S: Serializer,
43            {
44                serializer.serialize_str(match self {
45                    $(Self::$variant => $value,)+
46                    Self::Unknown(value) => value.as_str(),
47                })
48            }
49        }
50
51        impl<'de> Deserialize<'de> for $name {
52            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53            where
54                D: Deserializer<'de>,
55            {
56                let value = String::deserialize(deserializer)?;
57                Ok(match value.as_str() {
58                    $($value => Self::$variant,)+
59                    _ => Self::Unknown(value),
60                })
61            }
62        }
63    };
64}
65
66string_enum!(
67    #[derive(Default)]
68    /// High-level Zenodo deposition workflow state.
69    DepositState {
70        /// Draft work is still in progress.
71        #[default]
72        InProgress => "inprogress",
73        /// Unpublished draft state returned by Zenodo's live API and examples.
74        Unsubmitted => "unsubmitted",
75        /// Processing completed successfully.
76        Done => "done",
77        /// Processing failed.
78        Error => "error"
79    }
80);
81
82string_enum!(
83    #[derive(Default)]
84    /// Publication status for a record payload.
85    RecordPublicationStatus {
86        /// The record is published and publicly visible.
87        #[default]
88        Published => "published",
89        /// The record is still a draft.
90        Draft => "draft"
91    }
92);
93
94/// Combined publication and processing status for a deposition.
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
96pub struct DepositionStatus {
97    /// Whether the deposition has been published.
98    #[serde(default)]
99    pub submitted: bool,
100    /// Zenodo's processing state for the deposition.
101    #[serde(default)]
102    pub state: DepositState,
103}
104
105/// File attached to a draft deposition.
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct DepositionFile {
108    /// Deposition file identifier.
109    pub id: DepositionFileId,
110    /// Original filename.
111    pub filename: String,
112    /// File size in bytes.
113    #[serde(default, deserialize_with = "deserialize_u64ish")]
114    pub filesize: u64,
115    /// Reported checksum, when present.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub checksum: Option<String>,
118    /// Additional untyped fields preserved for forward compatibility.
119    #[serde(flatten, default)]
120    pub extra: BTreeMap<String, Value>,
121}
122
123/// Link relations returned on a deposition resource.
124#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
125pub struct DepositionLinks {
126    /// Canonical API URL for the deposition.
127    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
128    pub self_: Option<Url>,
129    /// Bucket URL used for draft file uploads.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub bucket: Option<BucketUrl>,
132    /// URL for the draft file listing.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub files: Option<Url>,
135    /// URL for the publish action.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub publish: Option<Url>,
138    /// URL for the edit action.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub edit: Option<Url>,
141    /// URL for the discard action.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub discard: Option<Url>,
144    /// URL for the latest editable draft after `newversion`.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub latest_draft: Option<Url>,
147    /// URL for the latest published record in the family.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub latest: Option<Url>,
150    /// URL for the versions listing.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub versions: Option<Url>,
153    /// Additional untyped fields preserved for forward compatibility.
154    #[serde(flatten, default)]
155    pub extra: BTreeMap<String, Value>,
156}
157
158/// Zenodo deposition resource, including draft and published depositions.
159#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160pub struct Deposition {
161    /// Deposition identifier.
162    pub id: DepositionId,
163    /// Concept record identifier shared across versions.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub conceptrecid: Option<ConceptRecId>,
166    /// Published record identifier, when available.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub record_id: Option<RecordId>,
169    /// Version-specific DOI, when available.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub doi: Option<Doi>,
172    /// Concept DOI shared across versions, when available.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub conceptdoi: Option<Doi>,
175    /// Publication and processing status fields.
176    #[serde(flatten)]
177    pub status: DepositionStatus,
178    /// Raw deposition metadata.
179    #[serde(default)]
180    pub metadata: Value,
181    /// Files currently visible on the deposition.
182    #[serde(default)]
183    pub files: Vec<DepositionFile>,
184    /// Known deposition link relations.
185    #[serde(default)]
186    pub links: DepositionLinks,
187    /// Additional untyped fields preserved for forward compatibility.
188    #[serde(flatten, default)]
189    pub extra: BTreeMap<String, Value>,
190}
191
192impl Deposition {
193    /// Returns `true` when the deposition has been published.
194    #[must_use]
195    pub fn is_published(&self) -> bool {
196        self.status.submitted
197    }
198
199    /// Returns `true` when Zenodo reports that metadata edits are allowed.
200    #[must_use]
201    pub fn allows_metadata_edits(&self) -> bool {
202        matches!(
203            self.status.state,
204            DepositState::InProgress | DepositState::Unsubmitted
205        )
206    }
207
208    /// Returns the `latest_draft` link, when present.
209    #[must_use]
210    pub fn latest_draft_url(&self) -> Option<&Url> {
211        self.links.latest_draft.as_ref()
212    }
213
214    /// Returns the bucket upload URL, when present.
215    #[must_use]
216    pub fn bucket_url(&self) -> Option<&BucketUrl> {
217        self.links.bucket.as_ref()
218    }
219}
220
221/// Result of a successful bucket upload.
222#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
223pub struct BucketObject {
224    /// Server-side object identifier, when present.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub id: Option<String>,
227    /// Uploaded object key.
228    pub key: String,
229    /// Object size in bytes.
230    #[serde(default, deserialize_with = "deserialize_u64ish")]
231    pub size: u64,
232    /// Reported checksum, when present.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub checksum: Option<String>,
235    /// Additional untyped fields preserved for forward compatibility.
236    #[serde(flatten, default)]
237    pub extra: BTreeMap<String, Value>,
238}
239
240/// Per-file links returned on a record file.
241#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
242pub struct RecordFileLinks {
243    /// Canonical API URL for the file.
244    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
245    pub self_: Option<Url>,
246    /// Direct content download URL.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub content: Option<Url>,
249    /// Additional untyped fields preserved for forward compatibility.
250    #[serde(flatten, default)]
251    pub extra: BTreeMap<String, Value>,
252}
253
254/// File attached to a published record.
255#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
256pub struct RecordFile {
257    /// Server-side file identifier.
258    pub id: String,
259    /// File key used for downloads.
260    pub key: String,
261    /// File size in bytes.
262    #[serde(default, deserialize_with = "deserialize_u64ish")]
263    pub size: u64,
264    /// Reported checksum, when present.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub checksum: Option<String>,
267    /// Known file link relations.
268    #[serde(default)]
269    pub links: RecordFileLinks,
270    /// Additional untyped fields preserved for forward compatibility.
271    #[serde(flatten, default)]
272    pub extra: BTreeMap<String, Value>,
273}
274
275impl RecordFile {
276    /// Returns the best download URL for the file.
277    #[must_use]
278    pub fn download_url(&self) -> Option<&Url> {
279        self.links.content.as_ref().or(self.links.self_.as_ref())
280    }
281}
282
283/// Link relations returned on a record resource.
284#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
285pub struct RecordLinks {
286    /// Canonical API URL for the record.
287    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
288    pub self_: Option<Url>,
289    /// Canonical HTML page for the record.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub self_html: Option<Url>,
292    /// Alternate HTML page link, when present.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub html: Option<Url>,
295    /// URL for the latest record version.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub latest: Option<Url>,
298    /// URL for the versions listing.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub versions: Option<Url>,
301    /// URL for the record's files listing.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub files: Option<Url>,
304    /// URL for downloading the record archive.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub archive: Option<Url>,
307    /// DOI URL, when present.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub doi: Option<Url>,
310    /// Additional untyped fields preserved for forward compatibility.
311    #[serde(flatten, default)]
312    pub extra: BTreeMap<String, Value>,
313}
314
315/// Basic record usage statistics.
316#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
317pub struct RecordStats {
318    /// Number of downloads, when Zenodo reports it.
319    #[serde(
320        default,
321        deserialize_with = "deserialize_option_u64ish",
322        skip_serializing_if = "Option::is_none"
323    )]
324    pub downloads: Option<u64>,
325    /// Number of views, when Zenodo reports it.
326    #[serde(
327        default,
328        deserialize_with = "deserialize_option_u64ish",
329        skip_serializing_if = "Option::is_none"
330    )]
331    pub views: Option<u64>,
332    /// Additional untyped fields preserved for forward compatibility.
333    #[serde(flatten, default)]
334    pub extra: BTreeMap<String, Value>,
335}
336
337/// Persistent identifier entry attached to a record or parent record.
338#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
339pub struct PersistentIdentifier {
340    /// Identifier value.
341    pub identifier: String,
342    /// PID provider identifier, when present.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub provider: Option<String>,
345    /// PID client identifier, when present.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub client: Option<String>,
348    /// Additional untyped fields preserved for forward compatibility.
349    #[serde(flatten, default)]
350    pub extra: BTreeMap<String, Value>,
351}
352
353/// Persistent identifier block attached to a record or parent record.
354#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
355pub struct RecordPids {
356    /// DOI PID entry, when present.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub doi: Option<PersistentIdentifier>,
359    /// Concept DOI PID entry, when present.
360    #[serde(
361        rename = "concept-doi",
362        default,
363        skip_serializing_if = "Option::is_none"
364    )]
365    pub concept_doi: Option<PersistentIdentifier>,
366    /// Record ID PID entry, when present.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub recid: Option<PersistentIdentifier>,
369    /// OAI PID entry, when present.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub oai: Option<PersistentIdentifier>,
372    /// Additional untyped fields preserved for forward compatibility.
373    #[serde(flatten, default)]
374    pub extra: BTreeMap<String, Value>,
375}
376
377/// Parent-community block attached to a published record.
378#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
379pub struct RecordParentCommunities {
380    /// Default community identifier, when present.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub default: Option<String>,
383    /// Community identifiers attached to the parent record.
384    #[serde(default)]
385    pub ids: Vec<String>,
386    /// Additional untyped fields preserved for forward compatibility.
387    #[serde(flatten, default)]
388    pub extra: BTreeMap<String, Value>,
389}
390
391/// Parent-record block attached to a published record.
392#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
393pub struct RecordParent {
394    /// Parent identifier, when present.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub id: Option<String>,
397    /// Parent communities block, when present.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub communities: Option<RecordParentCommunities>,
400    /// Parent persistent identifiers block, when present.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub pids: Option<RecordPids>,
403    /// Additional untyped fields preserved for forward compatibility.
404    #[serde(flatten, default)]
405    pub extra: BTreeMap<String, Value>,
406}
407
408/// Published record resource returned by Zenodo's records API.
409#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
410pub struct Record {
411    /// Record creation timestamp, when present.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub created: Option<DateTime<Utc>>,
414    /// Record modification timestamp, when present.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub modified: Option<DateTime<Utc>>,
417    /// Record identifier.
418    pub id: RecordId,
419    /// String-form record identifier as returned by Zenodo.
420    #[serde(deserialize_with = "deserialize_stringish")]
421    pub recid: String,
422    /// Concept record identifier shared across versions.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub conceptrecid: Option<ConceptRecId>,
425    /// Version-specific DOI, when present.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub doi: Option<Doi>,
428    /// Concept DOI shared across versions, when present.
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub conceptdoi: Option<Doi>,
431    /// Typed record metadata.
432    #[serde(default)]
433    pub metadata: RecordMetadata,
434    /// Files exposed on the record.
435    #[serde(default)]
436    pub files: Vec<RecordFile>,
437    /// Known record link relations.
438    #[serde(default)]
439    pub links: RecordLinks,
440    /// Parent record block, when present.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub parent: Option<RecordParent>,
443    /// Persistent identifiers block, when present.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub pids: Option<RecordPids>,
446    /// Record usage statistics, when present.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub stats: Option<RecordStats>,
449    /// Publication status reported by Zenodo.
450    #[serde(default)]
451    pub status: RecordPublicationStatus,
452    /// Revision number, when present.
453    #[serde(
454        default,
455        deserialize_with = "deserialize_option_u64ish",
456        skip_serializing_if = "Option::is_none"
457    )]
458    pub revision: Option<u64>,
459    /// Additional untyped fields preserved for forward compatibility.
460    #[serde(flatten, default)]
461    pub extra: BTreeMap<String, Value>,
462}
463
464#[derive(Deserialize)]
465struct RecordWire {
466    #[serde(default)]
467    created: Option<DateTime<Utc>>,
468    #[serde(default)]
469    modified: Option<DateTime<Utc>>,
470    #[serde(default)]
471    updated: Option<DateTime<Utc>>,
472    id: RecordId,
473    #[serde(deserialize_with = "deserialize_stringish")]
474    recid: String,
475    #[serde(default)]
476    conceptrecid: Option<ConceptRecId>,
477    #[serde(default)]
478    doi: Option<Doi>,
479    #[serde(default)]
480    conceptdoi: Option<Doi>,
481    #[serde(default)]
482    metadata: RecordMetadata,
483    #[serde(default)]
484    files: Vec<RecordFile>,
485    #[serde(default)]
486    links: RecordLinks,
487    #[serde(default)]
488    parent: Option<RecordParent>,
489    #[serde(default)]
490    pids: Option<RecordPids>,
491    #[serde(default)]
492    stats: Option<RecordStats>,
493    #[serde(default)]
494    status: RecordPublicationStatus,
495    #[serde(default, deserialize_with = "deserialize_option_u64ish")]
496    revision: Option<u64>,
497    #[serde(flatten, default)]
498    extra: BTreeMap<String, Value>,
499}
500
501impl<'de> Deserialize<'de> for Record {
502    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
503    where
504        D: Deserializer<'de>,
505    {
506        let wire = RecordWire::deserialize(deserializer)?;
507        Ok(Self {
508            created: wire.created,
509            modified: wire.modified.or(wire.updated),
510            id: wire.id,
511            recid: wire.recid,
512            conceptrecid: wire.conceptrecid,
513            doi: wire.doi,
514            conceptdoi: wire.conceptdoi,
515            metadata: wire.metadata,
516            files: wire.files,
517            links: wire.links,
518            parent: wire.parent,
519            pids: wire.pids,
520            stats: wire.stats,
521            status: wire.status,
522            revision: wire.revision,
523            extra: wire.extra,
524        })
525    }
526}
527
528impl Record {
529    /// Returns the link to the latest record version, when present.
530    #[must_use]
531    pub fn latest_url(&self) -> Option<&Url> {
532        self.links.latest.as_ref()
533    }
534
535    /// Returns the record archive link, when present.
536    #[must_use]
537    pub fn archive_url(&self) -> Option<&Url> {
538        self.links.archive.as_ref()
539    }
540
541    /// Finds a file by exact key.
542    #[must_use]
543    pub fn file_by_key(&self, key: &str) -> Option<&RecordFile> {
544        self.files.iter().find(|file| file.key == key)
545    }
546}
547
548/// Aggregated record details used by higher-level artifact helpers.
549#[derive(Clone, Debug, PartialEq, Eq)]
550pub struct ArtifactInfo {
551    /// The originally requested record.
552    pub record: Record,
553    /// The latest resolved record in the same family.
554    pub latest: Record,
555    /// Latest record files indexed by key.
556    pub files_by_key: BTreeMap<String, RecordFile>,
557}
558
559/// Result of a complete publish workflow.
560#[derive(Clone, Debug, PartialEq, Eq)]
561pub struct PublishedRecord {
562    /// Final deposition payload after publishing.
563    pub deposition: Deposition,
564    /// Published record fetched from the records API.
565    pub record: Record,
566}
567
568#[cfg(test)]
569mod tests {
570    use super::{
571        BucketObject, DepositState, Deposition, DepositionFile, Record, RecordFile, RecordLinks,
572        RecordPublicationStatus,
573    };
574    use serde_json::json;
575
576    #[test]
577    fn preserves_unknown_record_fields() {
578        let record: Record = serde_json::from_value(json!({
579            "created": "2026-04-03T12:00:00+00:00",
580            "updated": "2026-04-03T13:00:00+00:00",
581            "id": 42,
582            "recid": "42",
583            "metadata": { "title": "artifact" },
584            "files": [],
585            "links": {},
586            "parent": {
587                "id": "parent-1",
588                "communities": {
589                    "default": "zenodo",
590                    "ids": ["zenodo", "sandbox"]
591                },
592                "pids": {
593                    "doi": {
594                        "identifier": "10.5281/zenodo.42"
595                    }
596                }
597            },
598            "pids": {
599                "doi": {
600                    "identifier": "10.5281/zenodo.42"
601                },
602                "concept-doi": {
603                    "identifier": "10.5281/zenodo.41"
604                }
605            },
606            "mystery": "value"
607        }))
608        .unwrap();
609
610        assert!(record.created.is_some());
611        assert!(record.modified.is_some());
612        assert_eq!(
613            record
614                .parent
615                .as_ref()
616                .and_then(|parent| parent.id.as_deref()),
617            Some("parent-1")
618        );
619        assert_eq!(
620            record
621                .pids
622                .as_ref()
623                .and_then(|pids| pids.doi.as_ref())
624                .map(|pid| pid.identifier.as_str()),
625            Some("10.5281/zenodo.42")
626        );
627        assert_eq!(record.extra.get("mystery"), Some(&json!("value")));
628    }
629
630    #[test]
631    fn record_accepts_community_id_alias_in_metadata() {
632        let record: Record = serde_json::from_value(json!({
633            "created": "2026-04-03T12:00:00+00:00",
634            "updated": "2026-04-03T13:00:00+00:00",
635            "id": 42,
636            "recid": "42",
637            "metadata": {
638                "title": "artifact",
639                "communities": [
640                    { "id": "earth-metabolome" }
641                ]
642            },
643            "files": [],
644            "links": {}
645        }))
646        .unwrap();
647
648        assert_eq!(record.metadata.communities.len(), 1);
649        assert_eq!(
650            record.metadata.communities[0].identifier,
651            "earth-metabolome"
652        );
653    }
654
655    #[test]
656    fn record_deserializes_minimized_live_19701295_shape() {
657        // Mirrors the fields used by downstream download code from the live Zenodo record.
658        let record: Record = serde_json::from_value(json!({
659            "created": "2026-04-03T12:00:00+00:00",
660            "updated": "2026-04-03T13:00:00+00:00",
661            "id": 19_701_295,
662            "recid": "19701295",
663            "doi": "10.5281/zenodo.19701295",
664            "metadata": {
665                "title": "NPClassifier distilled dataset",
666                "publication_date": "2026-04-03",
667                "creators": [{ "name": "Doe, Jane" }],
668                "access_right": "open",
669                "resource_type": { "type": "dataset", "title": "Dataset" },
670                "communities": [
671                    { "id": "earth-metabolome" }
672                ]
673            },
674            "files": [
675                {
676                    "id": "f1",
677                    "key": "npclassifier-distilled.parquet",
678                    "size": 123_456,
679                    "checksum": "md5:0123456789abcdef0123456789abcdef",
680                    "links": {
681                        "self": "https://zenodo.org/api/records/19701295/files/npclassifier-distilled.parquet",
682                        "content": "https://zenodo.org/records/19701295/files/npclassifier-distilled.parquet/content"
683                    }
684                }
685            ],
686            "links": {
687                "self": "https://zenodo.org/api/records/19701295",
688                "self_html": "https://zenodo.org/records/19701295",
689                "html": "https://zenodo.org/records/19701295",
690                "latest": "https://zenodo.org/api/records/19701295",
691                "versions": "https://zenodo.org/api/records/19701295/versions",
692                "files": "https://zenodo.org/api/records/19701295/files",
693                "archive": "https://zenodo.org/api/records/19701295/files-archive",
694                "doi": "https://doi.org/10.5281/zenodo.19701295"
695            },
696            "pids": {
697                "doi": {
698                    "identifier": "10.5281/zenodo.19701295"
699                },
700                "recid": {
701                    "identifier": "19701295"
702                }
703            },
704            "stats": {
705                "downloads": 0,
706                "views": 0
707            },
708            "status": "published"
709        }))
710        .unwrap();
711
712        assert_eq!(record.id.0, 19_701_295);
713        assert_eq!(record.recid, "19701295");
714        assert_eq!(
715            record.doi.as_ref().map(crate::Doi::as_str),
716            Some("10.5281/zenodo.19701295")
717        );
718        assert_eq!(record.metadata.communities.len(), 1);
719        assert_eq!(
720            record.metadata.communities[0].identifier,
721            "earth-metabolome"
722        );
723        assert_eq!(
724            record
725                .file_by_key("npclassifier-distilled.parquet")
726                .and_then(|file| file.download_url())
727                .map(url::Url::as_str),
728            Some(
729                "https://zenodo.org/records/19701295/files/npclassifier-distilled.parquet/content"
730            )
731        );
732    }
733
734    #[test]
735    fn record_accepts_both_modified_and_updated_timestamps() {
736        let record: Record = serde_json::from_value(json!({
737            "created": "2026-04-03T12:00:00+00:00",
738            "modified": "2026-04-03T14:00:00+00:00",
739            "updated": "2026-04-03T13:00:00+00:00",
740            "id": 42,
741            "recid": 42.0,
742            "metadata": { "title": "artifact" },
743            "files": [],
744            "links": {}
745        }))
746        .unwrap();
747
748        assert!(record.created.is_some());
749        assert_eq!(
750            record.modified.unwrap().to_rfc3339(),
751            "2026-04-03T14:00:00+00:00"
752        );
753    }
754
755    #[test]
756    fn model_string_enums_preserve_unknown_values() {
757        let state: DepositState = serde_json::from_value(json!("queued")).unwrap();
758        let status: RecordPublicationStatus = serde_json::from_value(json!("embargoed")).unwrap();
759        let unsubmitted: DepositState = serde_json::from_value(json!("unsubmitted")).unwrap();
760
761        assert_eq!(serde_json::to_value(&state).unwrap(), json!("queued"));
762        assert_eq!(serde_json::to_value(&status).unwrap(), json!("embargoed"));
763        assert_eq!(
764            serde_json::to_value(&unsubmitted).unwrap(),
765            json!("unsubmitted")
766        );
767        assert!(matches!(state, DepositState::Unknown(value) if value == "queued"));
768        assert_eq!(unsubmitted, DepositState::Unsubmitted);
769        assert!(matches!(status, RecordPublicationStatus::Unknown(value) if value == "embargoed"));
770    }
771
772    #[test]
773    fn follows_latest_draft_link() {
774        let deposition: Deposition = serde_json::from_value(serde_json::json!({
775            "id": 7,
776            "submitted": true,
777            "state": "done",
778            "metadata": {},
779            "files": [],
780            "links": {
781                "latest_draft": "https://zenodo.org/api/deposit/depositions/8"
782            }
783        }))
784        .unwrap();
785
786        assert_eq!(
787            deposition.latest_draft_url().unwrap().as_str(),
788            "https://zenodo.org/api/deposit/depositions/8"
789        );
790    }
791
792    #[test]
793    fn record_file_prefers_content_link() {
794        let file: RecordFile = serde_json::from_value(serde_json::json!({
795            "id": "f1",
796            "key": "artifact.bin",
797            "links": {
798                "self": "https://zenodo.org/api/files/self",
799                "content": "https://zenodo.org/api/files/content"
800            }
801        }))
802        .unwrap();
803
804        assert_eq!(
805            file.download_url().unwrap().as_str(),
806            "https://zenodo.org/api/files/content"
807        );
808    }
809
810    #[test]
811    fn deposition_file_accepts_integer_like_numeric_shapes() {
812        let float_file: DepositionFile = serde_json::from_value(serde_json::json!({
813            "id": "f1",
814            "filename": "artifact.bin",
815            "filesize": 14.0
816        }))
817        .unwrap();
818        assert_eq!(float_file.filesize, 14);
819
820        let string_file: DepositionFile = serde_json::from_value(serde_json::json!({
821            "id": "f2",
822            "filename": "artifact.bin",
823            "filesize": "18"
824        }))
825        .unwrap();
826        assert_eq!(string_file.filesize, 18);
827    }
828
829    #[test]
830    fn deposition_file_rejects_non_integral_sizes() {
831        let error = serde_json::from_value::<DepositionFile>(serde_json::json!({
832            "id": "f1",
833            "filename": "artifact.bin",
834            "filesize": 14.5
835        }))
836        .unwrap_err();
837        assert!(error.to_string().contains("integer-like"));
838    }
839
840    #[test]
841    fn other_numeric_response_fields_accept_integer_like_shapes() {
842        let uploaded: BucketObject = serde_json::from_value(serde_json::json!({
843            "key": "artifact.bin",
844            "size": "14"
845        }))
846        .unwrap();
847        let published_file: RecordFile = serde_json::from_value(serde_json::json!({
848            "id": "f1",
849            "key": "artifact.bin",
850            "size": 15.0,
851            "links": {}
852        }))
853        .unwrap();
854        let record: Record = serde_json::from_value(serde_json::json!({
855            "id": 42.0,
856            "recid": 42.0,
857            "metadata": { "title": "artifact" },
858            "files": [],
859            "links": {},
860            "stats": {
861                "downloads": "16",
862                "views": 17.0
863            },
864            "revision": "18"
865        }))
866        .unwrap();
867
868        assert_eq!(uploaded.size, 14);
869        assert_eq!(published_file.size, 15);
870        assert_eq!(record.id.0, 42);
871        assert_eq!(
872            record.stats.as_ref().and_then(|stats| stats.downloads),
873            Some(16)
874        );
875        assert_eq!(
876            record.stats.as_ref().and_then(|stats| stats.views),
877            Some(17)
878        );
879        assert_eq!(record.revision, Some(18));
880    }
881
882    #[test]
883    fn record_and_deposition_helpers_expose_status_and_links() {
884        let deposition: Deposition = serde_json::from_value(serde_json::json!({
885            "id": 9,
886            "submitted": false,
887            "state": "mystery-state",
888            "metadata": {},
889            "files": [],
890            "links": {
891                "bucket": "https://zenodo.org/api/files/bucket-9"
892            }
893        }))
894        .unwrap();
895        let record = Record {
896            created: None,
897            modified: None,
898            id: crate::RecordId(10),
899            recid: "10".into(),
900            conceptrecid: None,
901            doi: None,
902            conceptdoi: None,
903            metadata: crate::RecordMetadata::default(),
904            files: Vec::new(),
905            links: RecordLinks::default(),
906            parent: None,
907            pids: None,
908            stats: None,
909            status: RecordPublicationStatus::Draft,
910            revision: None,
911            extra: std::collections::BTreeMap::new(),
912        };
913
914        assert!(!deposition.is_published());
915        assert!(deposition.bucket_url().is_some());
916        assert!(matches!(deposition.status.state, DepositState::Unknown(_)));
917        assert!(record.latest_url().is_none());
918        assert!(record.archive_url().is_none());
919    }
920
921    #[test]
922    fn deposition_helpers_treat_unsubmitted_as_editable() {
923        let deposition: Deposition = serde_json::from_value(serde_json::json!({
924            "id": 11,
925            "submitted": false,
926            "state": "unsubmitted",
927            "metadata": {},
928            "files": [],
929            "links": {}
930        }))
931        .unwrap();
932
933        assert_eq!(deposition.status.state, DepositState::Unsubmitted);
934        assert!(deposition.allows_metadata_edits());
935    }
936}