Skip to main content

zenodo_rs/
metadata.rs

1//! Typed metadata models for deposit updates and published records.
2//!
3//! This module contains the typed request and response shapes that matter most
4//! for Zenodo publishing and retrieval.
5//!
6//! The most important entrypoints are:
7//!
8//! - [`DepositMetadataUpdate::builder`] for draft metadata updates
9//! - [`Creator::builder`] and the other small builders for nested metadata
10//! - [`RecordMetadata`] for typed fields on published records
11//!
12//! Unknown Zenodo fields are still preserved through flattened `extra` maps so
13//! the crate remains forward compatible with mild schema drift.
14
15use std::collections::BTreeMap;
16
17use chrono::NaiveDate;
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use serde_json::Value;
20use thiserror::Error;
21use url::Url;
22
23use crate::serde_util::deserialize_option_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    /// Zenodo upload-type vocabulary for deposit metadata.
68    UploadType {
69        /// Dataset upload type.
70        Dataset => "dataset",
71        /// Publication upload type.
72        Publication => "publication",
73        /// Poster upload type.
74        Poster => "poster",
75        /// Presentation upload type.
76        Presentation => "presentation",
77        /// Software upload type.
78        Software => "software",
79        /// Image upload type.
80        Image => "image",
81        /// Video upload type.
82        Video => "video",
83        /// Lesson upload type.
84        Lesson => "lesson",
85        /// Physical object upload type.
86        PhysicalObject => "physicalobject",
87        /// Other upload type.
88        Other => "other"
89    }
90);
91
92string_enum!(
93    /// Zenodo access-right vocabulary for deposit metadata.
94    AccessRight {
95        /// Open access.
96        Open => "open",
97        /// Embargoed access.
98        Embargoed => "embargoed",
99        /// Restricted access.
100        Restricted => "restricted",
101        /// Closed access.
102        Closed => "closed"
103    }
104);
105
106/// Creator entry used by Zenodo metadata.
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
108pub struct Creator {
109    /// Full creator name in Zenodo's expected display form.
110    pub name: String,
111    /// Affiliation for the creator.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub affiliation: Option<String>,
114    /// ORCID identifier for the creator.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub orcid: Option<String>,
117    /// GND identifier for the creator.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub gnd: Option<String>,
120    /// Additional untyped fields preserved for forward compatibility.
121    #[serde(flatten, default)]
122    pub extra: BTreeMap<String, Value>,
123}
124
125impl Creator {
126    /// Starts building a creator entry.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use zenodo_rs::Creator;
132    ///
133    /// let creator = Creator::builder()
134    ///     .name("Doe, Jane")
135    ///     .affiliation("Zenodo")
136    ///     .build()?;
137    ///
138    /// assert_eq!(creator.name, "Doe, Jane");
139    /// assert_eq!(creator.affiliation.as_deref(), Some("Zenodo"));
140    /// # Ok::<(), zenodo_rs::MetadataEntryBuildError>(())
141    /// ```
142    #[must_use]
143    pub fn builder() -> CreatorBuilder {
144        CreatorBuilder::default()
145    }
146
147    /// Creates a creator entry with only the required name field.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use zenodo_rs::Creator;
153    ///
154    /// let creator = Creator::named("Doe, Jane");
155    /// assert_eq!(creator.name, "Doe, Jane");
156    /// assert!(creator.affiliation.is_none());
157    /// ```
158    #[must_use]
159    pub fn named(name: impl Into<String>) -> Self {
160        Self {
161            name: name.into(),
162            ..Self::default()
163        }
164    }
165}
166
167/// Builder for [`Creator`] values.
168#[derive(Clone, Debug, PartialEq, Eq, Default)]
169pub struct CreatorBuilder {
170    name: Option<String>,
171    affiliation: Option<String>,
172    orcid: Option<String>,
173    gnd: Option<String>,
174    extra: BTreeMap<String, Value>,
175}
176
177impl CreatorBuilder {
178    /// Sets the creator name.
179    #[must_use]
180    pub fn name(mut self, name: impl Into<String>) -> Self {
181        self.name = Some(name.into());
182        self
183    }
184
185    /// Sets the creator affiliation.
186    #[must_use]
187    pub fn affiliation(mut self, affiliation: impl Into<String>) -> Self {
188        self.affiliation = Some(affiliation.into());
189        self
190    }
191
192    /// Sets the creator ORCID.
193    #[must_use]
194    pub fn orcid(mut self, orcid: impl Into<String>) -> Self {
195        self.orcid = Some(orcid.into());
196        self
197    }
198
199    /// Sets the creator GND identifier.
200    #[must_use]
201    pub fn gnd(mut self, gnd: impl Into<String>) -> Self {
202        self.gnd = Some(gnd.into());
203        self
204    }
205
206    /// Replaces all extra untyped fields.
207    #[must_use]
208    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
209        self.extra = extra;
210        self
211    }
212
213    /// Adds one extra untyped field.
214    #[must_use]
215    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
216        self.extra.insert(key.into(), value);
217        self
218    }
219
220    /// Builds the creator entry.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the required `name` field is missing.
225    pub fn build(self) -> Result<Creator, MetadataEntryBuildError> {
226        Ok(Creator {
227            name: required_entry_field(self.name, "Creator", "name")?,
228            affiliation: self.affiliation,
229            orcid: self.orcid,
230            gnd: self.gnd,
231            extra: self.extra,
232        })
233    }
234}
235
236/// Contributor entry used by published-record metadata.
237#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
238pub struct Contributor {
239    /// Full contributor name in Zenodo's expected display form.
240    pub name: String,
241    /// Contributor role label as reported by Zenodo.
242    #[serde(rename = "type")]
243    pub type_: String,
244    /// Affiliation for the contributor.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub affiliation: Option<String>,
247    /// ORCID identifier for the contributor.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub orcid: Option<String>,
250    /// GND identifier for the contributor.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub gnd: Option<String>,
253    /// Additional untyped fields preserved for forward compatibility.
254    #[serde(flatten, default)]
255    pub extra: BTreeMap<String, Value>,
256}
257
258impl Contributor {
259    /// Starts building a contributor entry.
260    #[must_use]
261    pub fn builder() -> ContributorBuilder {
262        ContributorBuilder::default()
263    }
264
265    /// Creates a contributor entry with the required name and role fields.
266    #[must_use]
267    pub fn new(name: impl Into<String>, role: impl Into<String>) -> Self {
268        Self {
269            name: name.into(),
270            type_: role.into(),
271            ..Self::default()
272        }
273    }
274}
275
276/// Builder for [`Contributor`] values.
277#[derive(Clone, Debug, PartialEq, Eq, Default)]
278pub struct ContributorBuilder {
279    name: Option<String>,
280    role: Option<String>,
281    affiliation: Option<String>,
282    orcid: Option<String>,
283    gnd: Option<String>,
284    extra: BTreeMap<String, Value>,
285}
286
287impl ContributorBuilder {
288    /// Sets the contributor name.
289    #[must_use]
290    pub fn name(mut self, name: impl Into<String>) -> Self {
291        self.name = Some(name.into());
292        self
293    }
294
295    /// Sets the contributor role.
296    #[must_use]
297    pub fn role(mut self, role: impl Into<String>) -> Self {
298        self.role = Some(role.into());
299        self
300    }
301
302    /// Sets the contributor affiliation.
303    #[must_use]
304    pub fn affiliation(mut self, affiliation: impl Into<String>) -> Self {
305        self.affiliation = Some(affiliation.into());
306        self
307    }
308
309    /// Sets the contributor ORCID.
310    #[must_use]
311    pub fn orcid(mut self, orcid: impl Into<String>) -> Self {
312        self.orcid = Some(orcid.into());
313        self
314    }
315
316    /// Sets the contributor GND identifier.
317    #[must_use]
318    pub fn gnd(mut self, gnd: impl Into<String>) -> Self {
319        self.gnd = Some(gnd.into());
320        self
321    }
322
323    /// Replaces all extra untyped fields.
324    #[must_use]
325    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
326        self.extra = extra;
327        self
328    }
329
330    /// Adds one extra untyped field.
331    #[must_use]
332    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
333        self.extra.insert(key.into(), value);
334        self
335    }
336
337    /// Builds the contributor entry.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if `name` or `role` is missing.
342    pub fn build(self) -> Result<Contributor, MetadataEntryBuildError> {
343        Ok(Contributor {
344            name: required_entry_field(self.name, "Contributor", "name")?,
345            type_: required_entry_field(self.role, "Contributor", "role")?,
346            affiliation: self.affiliation,
347            orcid: self.orcid,
348            gnd: self.gnd,
349            extra: self.extra,
350        })
351    }
352}
353
354/// Subject classification attached to a published record.
355#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
356pub struct Subject {
357    /// Subject term or label.
358    pub term: String,
359    /// Subject identifier, when present.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub identifier: Option<String>,
362    /// Subject classification scheme, when present.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub scheme: Option<String>,
365    /// Additional untyped fields preserved for forward compatibility.
366    #[serde(flatten, default)]
367    pub extra: BTreeMap<String, Value>,
368}
369
370impl Subject {
371    /// Starts building a subject entry.
372    #[must_use]
373    pub fn builder() -> SubjectBuilder {
374        SubjectBuilder::default()
375    }
376
377    /// Creates a subject entry with only the required term field.
378    #[must_use]
379    pub fn new(term: impl Into<String>) -> Self {
380        Self {
381            term: term.into(),
382            ..Self::default()
383        }
384    }
385}
386
387/// Builder for [`Subject`] values.
388#[derive(Clone, Debug, PartialEq, Eq, Default)]
389pub struct SubjectBuilder {
390    term: Option<String>,
391    identifier: Option<String>,
392    scheme: Option<String>,
393    extra: BTreeMap<String, Value>,
394}
395
396impl SubjectBuilder {
397    /// Sets the subject term.
398    #[must_use]
399    pub fn term(mut self, term: impl Into<String>) -> Self {
400        self.term = Some(term.into());
401        self
402    }
403
404    /// Sets the subject identifier.
405    #[must_use]
406    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
407        self.identifier = Some(identifier.into());
408        self
409    }
410
411    /// Sets the subject scheme.
412    #[must_use]
413    pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
414        self.scheme = Some(scheme.into());
415        self
416    }
417
418    /// Replaces all extra untyped fields.
419    #[must_use]
420    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
421        self.extra = extra;
422        self
423    }
424
425    /// Adds one extra untyped field.
426    #[must_use]
427    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
428        self.extra.insert(key.into(), value);
429        self
430    }
431
432    /// Builds the subject entry.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if the required `term` field is missing.
437    pub fn build(self) -> Result<Subject, MetadataEntryBuildError> {
438        Ok(Subject {
439            term: required_entry_field(self.term, "Subject", "term")?,
440            identifier: self.identifier,
441            scheme: self.scheme,
442            extra: self.extra,
443        })
444    }
445}
446
447/// Additional identifier attached to a published record.
448#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
449pub struct RecordIdentifier {
450    /// Identifier value.
451    pub identifier: String,
452    /// Identifier scheme, when present.
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub scheme: Option<String>,
455    /// Additional untyped fields preserved for forward compatibility.
456    #[serde(flatten, default)]
457    pub extra: BTreeMap<String, Value>,
458}
459
460impl RecordIdentifier {
461    /// Starts building a record identifier entry.
462    #[must_use]
463    pub fn builder() -> RecordIdentifierBuilder {
464        RecordIdentifierBuilder::default()
465    }
466
467    /// Creates an identifier entry with only the required identifier field.
468    #[must_use]
469    pub fn new(identifier: impl Into<String>) -> Self {
470        Self {
471            identifier: identifier.into(),
472            ..Self::default()
473        }
474    }
475}
476
477/// Builder for [`RecordIdentifier`] values.
478#[derive(Clone, Debug, PartialEq, Eq, Default)]
479pub struct RecordIdentifierBuilder {
480    identifier: Option<String>,
481    scheme: Option<String>,
482    extra: BTreeMap<String, Value>,
483}
484
485impl RecordIdentifierBuilder {
486    /// Sets the identifier value.
487    #[must_use]
488    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
489        self.identifier = Some(identifier.into());
490        self
491    }
492
493    /// Sets the identifier scheme.
494    #[must_use]
495    pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
496        self.scheme = Some(scheme.into());
497        self
498    }
499
500    /// Replaces all extra untyped fields.
501    #[must_use]
502    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
503        self.extra = extra;
504        self
505    }
506
507    /// Adds one extra untyped field.
508    #[must_use]
509    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
510        self.extra.insert(key.into(), value);
511        self
512    }
513
514    /// Builds the identifier entry.
515    ///
516    /// # Errors
517    ///
518    /// Returns an error if the required `identifier` field is missing.
519    pub fn build(self) -> Result<RecordIdentifier, MetadataEntryBuildError> {
520        Ok(RecordIdentifier {
521            identifier: required_entry_field(self.identifier, "RecordIdentifier", "identifier")?,
522            scheme: self.scheme,
523            extra: self.extra,
524        })
525    }
526}
527
528/// Date entry attached to a published record.
529#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
530pub struct RecordDate {
531    /// Date value exactly as reported by Zenodo.
532    pub date: String,
533    /// Date type label, when present.
534    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
535    pub type_: Option<String>,
536    /// Human-readable date description, when present.
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub description: Option<String>,
539    /// Additional untyped fields preserved for forward compatibility.
540    #[serde(flatten, default)]
541    pub extra: BTreeMap<String, Value>,
542}
543
544impl RecordDate {
545    /// Starts building a record date entry.
546    #[must_use]
547    pub fn builder() -> RecordDateBuilder {
548        RecordDateBuilder::default()
549    }
550
551    /// Creates a record date entry with only the required date field.
552    #[must_use]
553    pub fn new(date: impl Into<String>) -> Self {
554        Self {
555            date: date.into(),
556            ..Self::default()
557        }
558    }
559}
560
561/// Builder for [`RecordDate`] values.
562#[derive(Clone, Debug, PartialEq, Eq, Default)]
563pub struct RecordDateBuilder {
564    date: Option<String>,
565    date_type: Option<String>,
566    description: Option<String>,
567    extra: BTreeMap<String, Value>,
568}
569
570impl RecordDateBuilder {
571    /// Sets the raw date value.
572    #[must_use]
573    pub fn date(mut self, date: impl Into<String>) -> Self {
574        self.date = Some(date.into());
575        self
576    }
577
578    /// Sets the Zenodo date type label.
579    #[must_use]
580    pub fn date_type(mut self, date_type: impl Into<String>) -> Self {
581        self.date_type = Some(date_type.into());
582        self
583    }
584
585    /// Sets the human-readable date description.
586    #[must_use]
587    pub fn description(mut self, description: impl Into<String>) -> Self {
588        self.description = Some(description.into());
589        self
590    }
591
592    /// Replaces all extra untyped fields.
593    #[must_use]
594    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
595        self.extra = extra;
596        self
597    }
598
599    /// Adds one extra untyped field.
600    #[must_use]
601    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
602        self.extra.insert(key.into(), value);
603        self
604    }
605
606    /// Builds the date entry.
607    ///
608    /// # Errors
609    ///
610    /// Returns an error if the required `date` field is missing.
611    pub fn build(self) -> Result<RecordDate, MetadataEntryBuildError> {
612        Ok(RecordDate {
613            date: required_entry_field(self.date, "RecordDate", "date")?,
614            type_: self.date_type,
615            description: self.description,
616            extra: self.extra,
617        })
618    }
619}
620
621/// Version-relation details reported for a published record.
622#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
623pub struct RecordVersionRelation {
624    /// Zero-based index of the current version, when Zenodo reports it.
625    #[serde(
626        default,
627        deserialize_with = "deserialize_option_u64ish",
628        skip_serializing_if = "Option::is_none"
629    )]
630    pub index: Option<u64>,
631    /// Total number of known versions, when Zenodo reports it.
632    #[serde(
633        default,
634        deserialize_with = "deserialize_option_u64ish",
635        skip_serializing_if = "Option::is_none"
636    )]
637    pub count: Option<u64>,
638    /// Whether this version is the latest known version, when Zenodo reports it.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub is_last: Option<bool>,
641    /// Additional untyped fields preserved for forward compatibility.
642    #[serde(flatten, default)]
643    pub extra: BTreeMap<String, Value>,
644}
645
646/// Relation blocks attached to a published record.
647#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
648pub struct RecordRelations {
649    /// Version-ordering relation details, when Zenodo reports them.
650    #[serde(default)]
651    pub version: Vec<RecordVersionRelation>,
652    /// Additional untyped fields preserved for forward compatibility.
653    #[serde(flatten, default)]
654    pub extra: BTreeMap<String, Value>,
655}
656
657/// Related identifier entry in Zenodo metadata.
658#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
659pub struct RelatedIdentifier {
660    /// Related identifier value.
661    pub identifier: String,
662    /// Relation type string used by Zenodo.
663    pub relation: String,
664    /// Identifier scheme, when supplied.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub scheme: Option<String>,
667    /// Related resource type, when supplied.
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub resource_type: Option<String>,
670    /// Additional untyped fields preserved for forward compatibility.
671    #[serde(flatten, default)]
672    pub extra: BTreeMap<String, Value>,
673}
674
675impl RelatedIdentifier {
676    /// Starts building a related identifier entry.
677    ///
678    /// # Examples
679    ///
680    /// ```
681    /// use zenodo_rs::RelatedIdentifier;
682    ///
683    /// let related = RelatedIdentifier::builder()
684    ///     .identifier("10.5281/zenodo.42")
685    ///     .relation("isSupplementTo")
686    ///     .scheme("doi")
687    ///     .build()?;
688    ///
689    /// assert_eq!(related.relation, "isSupplementTo");
690    /// # Ok::<(), zenodo_rs::MetadataEntryBuildError>(())
691    /// ```
692    #[must_use]
693    pub fn builder() -> RelatedIdentifierBuilder {
694        RelatedIdentifierBuilder::default()
695    }
696
697    /// Creates a related identifier entry with required identifier and relation fields.
698    #[must_use]
699    pub fn new(identifier: impl Into<String>, relation: impl Into<String>) -> Self {
700        Self {
701            identifier: identifier.into(),
702            relation: relation.into(),
703            ..Self::default()
704        }
705    }
706}
707
708/// Builder for [`RelatedIdentifier`] values.
709#[derive(Clone, Debug, PartialEq, Eq, Default)]
710pub struct RelatedIdentifierBuilder {
711    identifier: Option<String>,
712    relation: Option<String>,
713    scheme: Option<String>,
714    resource_type: Option<String>,
715    extra: BTreeMap<String, Value>,
716}
717
718impl RelatedIdentifierBuilder {
719    /// Sets the identifier value.
720    #[must_use]
721    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
722        self.identifier = Some(identifier.into());
723        self
724    }
725
726    /// Sets the relation type.
727    #[must_use]
728    pub fn relation(mut self, relation: impl Into<String>) -> Self {
729        self.relation = Some(relation.into());
730        self
731    }
732
733    /// Sets the identifier scheme.
734    #[must_use]
735    pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
736        self.scheme = Some(scheme.into());
737        self
738    }
739
740    /// Sets the related resource type.
741    #[must_use]
742    pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
743        self.resource_type = Some(resource_type.into());
744        self
745    }
746
747    /// Replaces all extra untyped fields.
748    #[must_use]
749    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
750        self.extra = extra;
751        self
752    }
753
754    /// Adds one extra untyped field.
755    #[must_use]
756    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
757        self.extra.insert(key.into(), value);
758        self
759    }
760
761    /// Builds the related identifier entry.
762    ///
763    /// # Errors
764    ///
765    /// Returns an error if `identifier` or `relation` is missing.
766    pub fn build(self) -> Result<RelatedIdentifier, MetadataEntryBuildError> {
767        Ok(RelatedIdentifier {
768            identifier: required_entry_field(self.identifier, "RelatedIdentifier", "identifier")?,
769            relation: required_entry_field(self.relation, "RelatedIdentifier", "relation")?,
770            scheme: self.scheme,
771            resource_type: self.resource_type,
772            extra: self.extra,
773        })
774    }
775}
776
777/// Reference to a Zenodo community.
778#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
779pub struct CommunityRef {
780    /// Community identifier.
781    #[serde(alias = "id")]
782    pub identifier: String,
783    /// Additional untyped fields preserved for forward compatibility.
784    #[serde(flatten, default)]
785    pub extra: BTreeMap<String, Value>,
786}
787
788impl CommunityRef {
789    /// Starts building a community reference.
790    #[must_use]
791    pub fn builder() -> CommunityRefBuilder {
792        CommunityRefBuilder::default()
793    }
794
795    /// Creates a community reference from its identifier.
796    ///
797    /// # Examples
798    ///
799    /// ```
800    /// use zenodo_rs::CommunityRef;
801    ///
802    /// let community = CommunityRef::new("zenodo");
803    /// assert_eq!(community.identifier, "zenodo");
804    /// ```
805    #[must_use]
806    pub fn new(identifier: impl Into<String>) -> Self {
807        Self {
808            identifier: identifier.into(),
809            ..Self::default()
810        }
811    }
812}
813
814/// Builder for [`CommunityRef`] values.
815#[derive(Clone, Debug, PartialEq, Eq, Default)]
816pub struct CommunityRefBuilder {
817    identifier: Option<String>,
818    extra: BTreeMap<String, Value>,
819}
820
821impl CommunityRefBuilder {
822    /// Sets the community identifier.
823    #[must_use]
824    pub fn identifier(mut self, identifier: impl Into<String>) -> Self {
825        self.identifier = Some(identifier.into());
826        self
827    }
828
829    /// Replaces all extra untyped fields.
830    #[must_use]
831    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
832        self.extra = extra;
833        self
834    }
835
836    /// Adds one extra untyped field.
837    #[must_use]
838    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
839        self.extra.insert(key.into(), value);
840        self
841    }
842
843    /// Builds the community reference.
844    ///
845    /// # Errors
846    ///
847    /// Returns an error if the required `identifier` field is missing.
848    pub fn build(self) -> Result<CommunityRef, MetadataEntryBuildError> {
849        Ok(CommunityRef {
850            identifier: required_entry_field(self.identifier, "CommunityRef", "identifier")?,
851            extra: self.extra,
852        })
853    }
854}
855
856/// Reference to a Zenodo grant.
857#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
858pub struct GrantRef {
859    /// Grant identifier.
860    pub id: String,
861    /// Additional untyped fields preserved for forward compatibility.
862    #[serde(flatten, default)]
863    pub extra: BTreeMap<String, Value>,
864}
865
866impl GrantRef {
867    /// Starts building a grant reference.
868    #[must_use]
869    pub fn builder() -> GrantRefBuilder {
870        GrantRefBuilder::default()
871    }
872
873    /// Creates a grant reference from its identifier.
874    ///
875    /// # Examples
876    ///
877    /// ```
878    /// use zenodo_rs::GrantRef;
879    ///
880    /// let grant = GrantRef::new("grant-1");
881    /// assert_eq!(grant.id, "grant-1");
882    /// ```
883    #[must_use]
884    pub fn new(id: impl Into<String>) -> Self {
885        Self {
886            id: id.into(),
887            ..Self::default()
888        }
889    }
890}
891
892/// Builder for [`GrantRef`] values.
893#[derive(Clone, Debug, PartialEq, Eq, Default)]
894pub struct GrantRefBuilder {
895    id: Option<String>,
896    extra: BTreeMap<String, Value>,
897}
898
899impl GrantRefBuilder {
900    /// Sets the grant identifier.
901    #[must_use]
902    pub fn id(mut self, id: impl Into<String>) -> Self {
903        self.id = Some(id.into());
904        self
905    }
906
907    /// Replaces all extra untyped fields.
908    #[must_use]
909    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
910        self.extra = extra;
911        self
912    }
913
914    /// Adds one extra untyped field.
915    #[must_use]
916    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
917        self.extra.insert(key.into(), value);
918        self
919    }
920
921    /// Builds the grant reference.
922    ///
923    /// # Errors
924    ///
925    /// Returns an error if the required `id` field is missing.
926    pub fn build(self) -> Result<GrantRef, MetadataEntryBuildError> {
927        Ok(GrantRef {
928            id: required_entry_field(self.id, "GrantRef", "id")?,
929            extra: self.extra,
930        })
931    }
932}
933
934/// License metadata attached to a published record.
935#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
936pub struct LicenseRef {
937    /// License identifier, when present.
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    pub id: Option<String>,
940    /// Human-readable license title, when present.
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub title: Option<String>,
943    /// Additional untyped fields preserved for forward compatibility.
944    #[serde(flatten, default)]
945    pub extra: BTreeMap<String, Value>,
946}
947
948impl LicenseRef {
949    /// Starts building a license reference.
950    #[must_use]
951    pub fn builder() -> LicenseRefBuilder {
952        LicenseRefBuilder::default()
953    }
954
955    /// Creates a license reference from a license identifier.
956    #[must_use]
957    pub fn new(id: impl Into<String>) -> Self {
958        Self {
959            id: Some(id.into()),
960            ..Self::default()
961        }
962    }
963}
964
965/// Builder for [`LicenseRef`] values.
966#[derive(Clone, Debug, PartialEq, Eq, Default)]
967pub struct LicenseRefBuilder {
968    id: Option<String>,
969    title: Option<String>,
970    extra: BTreeMap<String, Value>,
971}
972
973impl LicenseRefBuilder {
974    /// Sets the license identifier.
975    #[must_use]
976    pub fn id(mut self, id: impl Into<String>) -> Self {
977        self.id = Some(id.into());
978        self
979    }
980
981    /// Sets the human-readable license title.
982    #[must_use]
983    pub fn title(mut self, title: impl Into<String>) -> Self {
984        self.title = Some(title.into());
985        self
986    }
987
988    /// Replaces all extra untyped fields.
989    #[must_use]
990    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
991        self.extra = extra;
992        self
993    }
994
995    /// Adds one extra untyped field.
996    #[must_use]
997    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
998        self.extra.insert(key.into(), value);
999        self
1000    }
1001
1002    /// Builds the license reference.
1003    #[must_use]
1004    pub fn build(self) -> LicenseRef {
1005        LicenseRef {
1006            id: self.id,
1007            title: self.title,
1008            extra: self.extra,
1009        }
1010    }
1011}
1012
1013/// Resource type details on published records.
1014#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
1015pub struct ResourceType {
1016    /// Top-level Zenodo resource type.
1017    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
1018    pub type_: Option<String>,
1019    /// Zenodo resource subtype.
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub subtype: Option<String>,
1022    /// Human-readable resource type title.
1023    #[serde(default, skip_serializing_if = "Option::is_none")]
1024    pub title: Option<String>,
1025    /// Additional untyped fields preserved for forward compatibility.
1026    #[serde(flatten, default)]
1027    pub extra: BTreeMap<String, Value>,
1028}
1029
1030impl ResourceType {
1031    /// Starts building a resource type entry.
1032    #[must_use]
1033    pub fn builder() -> ResourceTypeBuilder {
1034        ResourceTypeBuilder::default()
1035    }
1036
1037    /// Creates a resource type entry from a top-level type string.
1038    #[must_use]
1039    pub fn new(type_: impl Into<String>) -> Self {
1040        Self {
1041            type_: Some(type_.into()),
1042            ..Self::default()
1043        }
1044    }
1045}
1046
1047/// Builder for [`ResourceType`] values.
1048#[derive(Clone, Debug, PartialEq, Eq, Default)]
1049pub struct ResourceTypeBuilder {
1050    type_: Option<String>,
1051    subtype: Option<String>,
1052    title: Option<String>,
1053    extra: BTreeMap<String, Value>,
1054}
1055
1056impl ResourceTypeBuilder {
1057    /// Sets the top-level resource type.
1058    #[must_use]
1059    pub fn type_(mut self, type_: impl Into<String>) -> Self {
1060        self.type_ = Some(type_.into());
1061        self
1062    }
1063
1064    /// Sets the resource subtype.
1065    #[must_use]
1066    pub fn subtype(mut self, subtype: impl Into<String>) -> Self {
1067        self.subtype = Some(subtype.into());
1068        self
1069    }
1070
1071    /// Sets the human-readable resource type title.
1072    #[must_use]
1073    pub fn title(mut self, title: impl Into<String>) -> Self {
1074        self.title = Some(title.into());
1075        self
1076    }
1077
1078    /// Replaces all extra untyped fields.
1079    #[must_use]
1080    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
1081        self.extra = extra;
1082        self
1083    }
1084
1085    /// Adds one extra untyped field.
1086    #[must_use]
1087    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
1088        self.extra.insert(key.into(), value);
1089        self
1090    }
1091
1092    /// Builds the resource type entry.
1093    #[must_use]
1094    pub fn build(self) -> ResourceType {
1095        ResourceType {
1096            type_: self.type_,
1097            subtype: self.subtype,
1098            title: self.title,
1099            extra: self.extra,
1100        }
1101    }
1102}
1103
1104/// Errors raised while building nested metadata entry values.
1105#[derive(Clone, Debug, PartialEq, Eq, Error)]
1106pub enum MetadataEntryBuildError {
1107    /// A required field was not provided to a metadata entry builder.
1108    #[error("missing required {entry} field: {field}")]
1109    MissingField {
1110        /// Name of the entry being built.
1111        entry: &'static str,
1112        /// Name of the missing field.
1113        field: &'static str,
1114    },
1115}
1116
1117fn required_entry_field<T>(
1118    value: Option<T>,
1119    entry: &'static str,
1120    field: &'static str,
1121) -> Result<T, MetadataEntryBuildError> {
1122    value.ok_or(MetadataEntryBuildError::MissingField { entry, field })
1123}
1124
1125/// Typed metadata payload used for deposition updates.
1126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1127pub struct DepositMetadataUpdate {
1128    /// Record title.
1129    pub title: String,
1130    /// Zenodo upload type.
1131    pub upload_type: UploadType,
1132    /// Publication date, when supplied.
1133    #[serde(default, skip_serializing_if = "Option::is_none")]
1134    pub publication_date: Option<NaiveDate>,
1135    /// HTML description body sent as Zenodo's `description` field.
1136    #[serde(rename = "description")]
1137    pub description_html: String,
1138    /// Creator list.
1139    #[serde(default)]
1140    pub creators: Vec<Creator>,
1141    /// Access-right setting.
1142    pub access_right: AccessRight,
1143    /// License identifier for open-access deposits, when supplied.
1144    #[serde(default, skip_serializing_if = "Option::is_none")]
1145    pub license: Option<String>,
1146    /// Free-form keywords.
1147    #[serde(default)]
1148    pub keywords: Vec<String>,
1149    /// Related identifier entries.
1150    #[serde(default)]
1151    pub related_identifiers: Vec<RelatedIdentifier>,
1152    /// Free-form notes field.
1153    #[serde(default, skip_serializing_if = "Option::is_none")]
1154    pub notes: Option<String>,
1155    /// Version string for the deposit, when supplied.
1156    #[serde(default, skip_serializing_if = "Option::is_none")]
1157    pub version: Option<String>,
1158    /// Target communities for the deposit.
1159    #[serde(default)]
1160    pub communities: Vec<CommunityRef>,
1161    /// Grant references for the deposit.
1162    #[serde(default)]
1163    pub grants: Vec<GrantRef>,
1164    /// Additional untyped fields preserved for forward compatibility.
1165    #[serde(flatten, default)]
1166    pub extra: BTreeMap<String, Value>,
1167}
1168
1169/// Errors raised while building [`DepositMetadataUpdate`] values.
1170#[derive(Clone, Debug, PartialEq, Eq, Error)]
1171pub enum DepositMetadataBuildError {
1172    /// A required metadata field was not provided to the builder.
1173    #[error("missing required deposit metadata field: {field}")]
1174    MissingField {
1175        /// Name of the missing field.
1176        field: &'static str,
1177    },
1178}
1179
1180/// Builder for [`DepositMetadataUpdate`].
1181#[derive(Clone, Debug, PartialEq, Eq, Default)]
1182pub struct DepositMetadataUpdateBuilder {
1183    title: Option<String>,
1184    upload_type: Option<UploadType>,
1185    publication_date: Option<NaiveDate>,
1186    description_html: Option<String>,
1187    creators: Vec<Creator>,
1188    access_right: Option<AccessRight>,
1189    license: Option<String>,
1190    keywords: Vec<String>,
1191    related_identifiers: Vec<RelatedIdentifier>,
1192    notes: Option<String>,
1193    version: Option<String>,
1194    communities: Vec<CommunityRef>,
1195    grants: Vec<GrantRef>,
1196    extra: BTreeMap<String, Value>,
1197}
1198
1199impl DepositMetadataUpdate {
1200    /// Starts building a deposit metadata update payload.
1201    ///
1202    /// # Examples
1203    ///
1204    /// ```
1205    /// use zenodo_rs::{AccessRight, Creator, DepositMetadataUpdate, UploadType};
1206    ///
1207    /// let metadata = DepositMetadataUpdate::builder()
1208    ///     .title("Example dataset")
1209    ///     .upload_type(UploadType::Dataset)
1210    ///     .description_html("<p>Example upload</p>")
1211    ///     .creator(
1212    ///         Creator::builder()
1213    ///             .name("Doe, Jane")
1214    ///             .affiliation("Zenodo")
1215    ///             .build()?,
1216    ///     )
1217    ///     .access_right(AccessRight::Open)
1218    ///     .build()?;
1219    ///
1220    /// assert_eq!(metadata.title, "Example dataset");
1221    /// assert_eq!(metadata.creators.len(), 1);
1222    /// # Ok::<(), Box<dyn std::error::Error>>(())
1223    /// ```
1224    #[must_use]
1225    pub fn builder() -> DepositMetadataUpdateBuilder {
1226        DepositMetadataUpdateBuilder::default()
1227    }
1228}
1229
1230impl DepositMetadataUpdateBuilder {
1231    /// Sets the record title.
1232    #[must_use]
1233    pub fn title(mut self, title: impl Into<String>) -> Self {
1234        self.title = Some(title.into());
1235        self
1236    }
1237
1238    /// Sets the Zenodo upload type.
1239    #[must_use]
1240    pub fn upload_type(mut self, upload_type: UploadType) -> Self {
1241        self.upload_type = Some(upload_type);
1242        self
1243    }
1244
1245    /// Sets the publication date.
1246    #[must_use]
1247    pub fn publication_date(mut self, publication_date: NaiveDate) -> Self {
1248        self.publication_date = Some(publication_date);
1249        self
1250    }
1251
1252    /// Sets the HTML description body.
1253    #[must_use]
1254    pub fn description_html(mut self, description_html: impl Into<String>) -> Self {
1255        self.description_html = Some(description_html.into());
1256        self
1257    }
1258
1259    /// Replaces the full creator list.
1260    #[must_use]
1261    pub fn creators(mut self, creators: Vec<Creator>) -> Self {
1262        self.creators = creators;
1263        self
1264    }
1265
1266    /// Adds one creator entry.
1267    #[must_use]
1268    pub fn creator(mut self, creator: Creator) -> Self {
1269        self.creators.push(creator);
1270        self
1271    }
1272
1273    /// Adds one creator entry by name only.
1274    #[must_use]
1275    pub fn creator_named(mut self, name: impl Into<String>) -> Self {
1276        self.creators.push(Creator::named(name));
1277        self
1278    }
1279
1280    /// Sets the access-right policy.
1281    #[must_use]
1282    pub fn access_right(mut self, access_right: AccessRight) -> Self {
1283        self.access_right = Some(access_right);
1284        self
1285    }
1286
1287    /// Sets the license identifier.
1288    #[must_use]
1289    pub fn license(mut self, license: impl Into<String>) -> Self {
1290        self.license = Some(license.into());
1291        self
1292    }
1293
1294    /// Replaces the keyword list.
1295    #[must_use]
1296    pub fn keywords(mut self, keywords: Vec<String>) -> Self {
1297        self.keywords = keywords;
1298        self
1299    }
1300
1301    /// Adds one keyword.
1302    #[must_use]
1303    pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
1304        self.keywords.push(keyword.into());
1305        self
1306    }
1307
1308    /// Replaces the related identifier list.
1309    #[must_use]
1310    pub fn related_identifiers(mut self, related_identifiers: Vec<RelatedIdentifier>) -> Self {
1311        self.related_identifiers = related_identifiers;
1312        self
1313    }
1314
1315    /// Adds one related identifier.
1316    #[must_use]
1317    pub fn related_identifier(mut self, related_identifier: RelatedIdentifier) -> Self {
1318        self.related_identifiers.push(related_identifier);
1319        self
1320    }
1321
1322    /// Sets free-form notes.
1323    #[must_use]
1324    pub fn notes(mut self, notes: impl Into<String>) -> Self {
1325        self.notes = Some(notes.into());
1326        self
1327    }
1328
1329    /// Sets the version string.
1330    #[must_use]
1331    pub fn version(mut self, version: impl Into<String>) -> Self {
1332        self.version = Some(version.into());
1333        self
1334    }
1335
1336    /// Replaces the community list.
1337    #[must_use]
1338    pub fn communities(mut self, communities: Vec<CommunityRef>) -> Self {
1339        self.communities = communities;
1340        self
1341    }
1342
1343    /// Adds one community reference.
1344    #[must_use]
1345    pub fn community(mut self, community: CommunityRef) -> Self {
1346        self.communities.push(community);
1347        self
1348    }
1349
1350    /// Adds one community reference by identifier only.
1351    #[must_use]
1352    pub fn community_identifier(mut self, identifier: impl Into<String>) -> Self {
1353        self.communities.push(CommunityRef::new(identifier));
1354        self
1355    }
1356
1357    /// Replaces the grant list.
1358    #[must_use]
1359    pub fn grants(mut self, grants: Vec<GrantRef>) -> Self {
1360        self.grants = grants;
1361        self
1362    }
1363
1364    /// Adds one grant reference.
1365    #[must_use]
1366    pub fn grant(mut self, grant: GrantRef) -> Self {
1367        self.grants.push(grant);
1368        self
1369    }
1370
1371    /// Adds one grant reference by identifier only.
1372    #[must_use]
1373    pub fn grant_id(mut self, id: impl Into<String>) -> Self {
1374        self.grants.push(GrantRef::new(id));
1375        self
1376    }
1377
1378    /// Replaces all extra untyped fields.
1379    #[must_use]
1380    pub fn extra(mut self, extra: BTreeMap<String, Value>) -> Self {
1381        self.extra = extra;
1382        self
1383    }
1384
1385    /// Adds one extra untyped field.
1386    #[must_use]
1387    pub fn extra_field(mut self, key: impl Into<String>, value: Value) -> Self {
1388        self.extra.insert(key.into(), value);
1389        self
1390    }
1391
1392    /// Builds the metadata payload.
1393    ///
1394    /// # Examples
1395    ///
1396    /// ```
1397    /// use zenodo_rs::{AccessRight, Creator, DepositMetadataUpdate, UploadType};
1398    ///
1399    /// let metadata = DepositMetadataUpdate::builder()
1400    ///     .title("Dataset")
1401    ///     .upload_type(UploadType::Dataset)
1402    ///     .description_html("<p>Ready for upload</p>")
1403    ///     .creator(Creator::builder().name("Doe, Jane").build()?)
1404    ///     .access_right(AccessRight::Open)
1405    ///     .keyword("rust")
1406    ///     .build()?;
1407    ///
1408    /// assert_eq!(metadata.keywords, vec!["rust"]);
1409    /// # Ok::<(), Box<dyn std::error::Error>>(())
1410    /// ```
1411    ///
1412    /// # Errors
1413    ///
1414    /// Returns an error if any required Zenodo metadata field is still
1415    /// missing.
1416    pub fn build(self) -> Result<DepositMetadataUpdate, DepositMetadataBuildError> {
1417        Ok(DepositMetadataUpdate {
1418            title: required(self.title, "title")?,
1419            upload_type: required(self.upload_type, "upload_type")?,
1420            publication_date: self.publication_date,
1421            description_html: required(self.description_html, "description_html")?,
1422            creators: required_non_empty(self.creators, "creators")?,
1423            access_right: required(self.access_right, "access_right")?,
1424            license: self.license,
1425            keywords: self.keywords,
1426            related_identifiers: self.related_identifiers,
1427            notes: self.notes,
1428            version: self.version,
1429            communities: self.communities,
1430            grants: self.grants,
1431            extra: self.extra,
1432        })
1433    }
1434}
1435
1436fn required<T>(value: Option<T>, field: &'static str) -> Result<T, DepositMetadataBuildError> {
1437    value.ok_or(DepositMetadataBuildError::MissingField { field })
1438}
1439
1440fn required_non_empty<T>(
1441    value: Vec<T>,
1442    field: &'static str,
1443) -> Result<Vec<T>, DepositMetadataBuildError> {
1444    if value.is_empty() {
1445        return Err(DepositMetadataBuildError::MissingField { field });
1446    }
1447
1448    Ok(value)
1449}
1450
1451/// Typed metadata returned on published records.
1452#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
1453pub struct RecordMetadata {
1454    /// Record title.
1455    pub title: String,
1456    /// Publication date, when present.
1457    #[serde(default, skip_serializing_if = "Option::is_none")]
1458    pub publication_date: Option<NaiveDate>,
1459    /// HTML description body, when present.
1460    #[serde(
1461        rename = "description",
1462        default,
1463        skip_serializing_if = "Option::is_none"
1464    )]
1465    pub description_html: Option<String>,
1466    /// Creator list.
1467    #[serde(default)]
1468    pub creators: Vec<Creator>,
1469    /// Contributor list.
1470    #[serde(default)]
1471    pub contributors: Vec<Contributor>,
1472    /// Free-form keywords.
1473    #[serde(default)]
1474    pub keywords: Vec<String>,
1475    /// Free-form reference strings.
1476    #[serde(default)]
1477    pub references: Vec<String>,
1478    /// Community references attached to the record.
1479    #[serde(default)]
1480    pub communities: Vec<CommunityRef>,
1481    /// Grant references attached to the record.
1482    #[serde(default)]
1483    pub grants: Vec<GrantRef>,
1484    /// Subject classifications attached to the record.
1485    #[serde(default)]
1486    pub subjects: Vec<Subject>,
1487    /// Additional identifier entries.
1488    #[serde(default)]
1489    pub identifiers: Vec<RecordIdentifier>,
1490    /// Alternate identifier entries.
1491    #[serde(default)]
1492    pub alternate_identifiers: Vec<RecordIdentifier>,
1493    /// Additional date entries.
1494    #[serde(default)]
1495    pub dates: Vec<RecordDate>,
1496    /// Related identifier entries.
1497    #[serde(default)]
1498    pub related_identifiers: Vec<RelatedIdentifier>,
1499    /// Resource type details, when present.
1500    #[serde(default, skip_serializing_if = "Option::is_none")]
1501    pub resource_type: Option<ResourceType>,
1502    /// Access-right details, when present.
1503    #[serde(default, skip_serializing_if = "Option::is_none")]
1504    pub access_right: Option<AccessRight>,
1505    /// Access conditions for restricted records, when present.
1506    #[serde(default, skip_serializing_if = "Option::is_none")]
1507    pub access_conditions: Option<String>,
1508    /// Embargo date for embargoed records, when present.
1509    #[serde(default, skip_serializing_if = "Option::is_none")]
1510    pub embargo_date: Option<NaiveDate>,
1511    /// License details, when present.
1512    #[serde(default, skip_serializing_if = "Option::is_none")]
1513    pub license: Option<LicenseRef>,
1514    /// Publisher string, when present.
1515    #[serde(default, skip_serializing_if = "Option::is_none")]
1516    pub publisher: Option<String>,
1517    /// Primary language string, when present.
1518    #[serde(default, skip_serializing_if = "Option::is_none")]
1519    pub language: Option<String>,
1520    /// Size labels attached to the record.
1521    #[serde(default)]
1522    pub sizes: Vec<String>,
1523    /// Format labels attached to the record.
1524    #[serde(default)]
1525    pub formats: Vec<String>,
1526    /// Free-form notes field, when present.
1527    #[serde(default, skip_serializing_if = "Option::is_none")]
1528    pub notes: Option<String>,
1529    /// Version string, when present.
1530    #[serde(default, skip_serializing_if = "Option::is_none")]
1531    pub version: Option<String>,
1532    /// Journal title, when present.
1533    #[serde(default, skip_serializing_if = "Option::is_none")]
1534    pub journal_title: Option<String>,
1535    /// Journal volume, when present.
1536    #[serde(default, skip_serializing_if = "Option::is_none")]
1537    pub journal_volume: Option<String>,
1538    /// Journal issue, when present.
1539    #[serde(default, skip_serializing_if = "Option::is_none")]
1540    pub journal_issue: Option<String>,
1541    /// Journal pages, when present.
1542    #[serde(default, skip_serializing_if = "Option::is_none")]
1543    pub journal_pages: Option<String>,
1544    /// Conference title, when present.
1545    #[serde(default, skip_serializing_if = "Option::is_none")]
1546    pub conference_title: Option<String>,
1547    /// Conference acronym, when present.
1548    #[serde(default, skip_serializing_if = "Option::is_none")]
1549    pub conference_acronym: Option<String>,
1550    /// Conference dates, when present.
1551    #[serde(default, skip_serializing_if = "Option::is_none")]
1552    pub conference_dates: Option<String>,
1553    /// Conference place, when present.
1554    #[serde(default, skip_serializing_if = "Option::is_none")]
1555    pub conference_place: Option<String>,
1556    /// Conference URL, when present.
1557    #[serde(default, skip_serializing_if = "Option::is_none")]
1558    pub conference_url: Option<Url>,
1559    /// Conference session, when present.
1560    #[serde(default, skip_serializing_if = "Option::is_none")]
1561    pub conference_session: Option<String>,
1562    /// Conference session part, when present.
1563    #[serde(default, skip_serializing_if = "Option::is_none")]
1564    pub conference_session_part: Option<String>,
1565    /// Imprint publisher, when present.
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    pub imprint_publisher: Option<String>,
1568    /// Imprint ISBN, when present.
1569    #[serde(default, skip_serializing_if = "Option::is_none")]
1570    pub imprint_isbn: Option<String>,
1571    /// Imprint place, when present.
1572    #[serde(default, skip_serializing_if = "Option::is_none")]
1573    pub imprint_place: Option<String>,
1574    /// Container title for book chapters, when present.
1575    #[serde(default, skip_serializing_if = "Option::is_none")]
1576    pub partof_title: Option<String>,
1577    /// Container page range for book chapters, when present.
1578    #[serde(default, skip_serializing_if = "Option::is_none")]
1579    pub partof_pages: Option<String>,
1580    /// Thesis supervisors, when present.
1581    #[serde(default)]
1582    pub thesis_supervisors: Vec<Creator>,
1583    /// Awarding university for a thesis, when present.
1584    #[serde(default, skip_serializing_if = "Option::is_none")]
1585    pub thesis_university: Option<String>,
1586    /// Relation blocks reported on the record, when present.
1587    #[serde(default, skip_serializing_if = "Option::is_none")]
1588    pub relations: Option<RecordRelations>,
1589    /// Additional untyped fields preserved for forward compatibility.
1590    #[serde(flatten, default)]
1591    pub extra: BTreeMap<String, Value>,
1592}
1593
1594#[cfg(test)]
1595mod tests {
1596    use super::{
1597        AccessRight, CommunityRef, Contributor, Creator, DepositMetadataBuildError,
1598        DepositMetadataUpdate, GrantRef, LicenseRef, MetadataEntryBuildError, RecordDate,
1599        RecordIdentifier, RecordMetadata, RecordRelations, RecordVersionRelation,
1600        RelatedIdentifier, ResourceType, Subject, UploadType,
1601    };
1602    use chrono::NaiveDate;
1603    use serde_json::json;
1604
1605    #[test]
1606    fn metadata_builders_start_with_empty_optional_collections() {
1607        let metadata = DepositMetadataUpdate::builder()
1608            .title("Title")
1609            .upload_type(UploadType::Dataset)
1610            .description_html("<p>Desc</p>")
1611            .creator(Creator::builder().name("Doe, Jane").build().unwrap())
1612            .access_right(AccessRight::Open)
1613            .build()
1614            .unwrap();
1615
1616        assert!(metadata.keywords.is_empty());
1617        assert!(metadata.related_identifiers.is_empty());
1618        assert!(metadata.communities.is_empty());
1619        assert!(metadata.grants.is_empty());
1620    }
1621
1622    #[test]
1623    fn metadata_builder_requires_missing_fields() {
1624        let error = DepositMetadataUpdate::builder()
1625            .title("Title")
1626            .upload_type(UploadType::Dataset)
1627            .access_right(AccessRight::Open)
1628            .build()
1629            .unwrap_err();
1630
1631        assert_eq!(
1632            error,
1633            DepositMetadataBuildError::MissingField {
1634                field: "description_html",
1635            }
1636        );
1637    }
1638
1639    #[test]
1640    fn metadata_builder_requires_at_least_one_creator() {
1641        let error = DepositMetadataUpdate::builder()
1642            .title("Title")
1643            .upload_type(UploadType::Dataset)
1644            .description_html("<p>Desc</p>")
1645            .access_right(AccessRight::Open)
1646            .build()
1647            .unwrap_err();
1648
1649        assert_eq!(
1650            error,
1651            DepositMetadataBuildError::MissingField { field: "creators" }
1652        );
1653    }
1654
1655    #[test]
1656    fn metadata_builder_supports_full_optional_surface() {
1657        let publication_date = NaiveDate::from_ymd_opt(2026, 4, 3).unwrap();
1658        let primary_creator = Creator::builder()
1659            .name("Doe, Jane")
1660            .affiliation("Zenodo")
1661            .orcid("0000-0000-0000-0001")
1662            .build()
1663            .unwrap();
1664        let secondary_creator = Creator::builder()
1665            .name("Doe, John")
1666            .gnd("123456")
1667            .build()
1668            .unwrap();
1669        let related_identifier = RelatedIdentifier::builder()
1670            .identifier("10.5281/zenodo.42")
1671            .relation("isSupplementTo")
1672            .scheme("doi")
1673            .resource_type("dataset")
1674            .build()
1675            .unwrap();
1676        let related_identifier_extra = RelatedIdentifier::builder()
1677            .identifier("https://example.org")
1678            .relation("references")
1679            .build()
1680            .unwrap();
1681        let community = CommunityRef::new("zenodo");
1682        let extra_community = CommunityRef::new("sandbox");
1683        let grant = GrantRef::new("grant-1");
1684        let extra_grant = GrantRef::new("grant-2");
1685        let mut extra = std::collections::BTreeMap::new();
1686        extra.insert("language".into(), json!("en"));
1687
1688        let metadata = DepositMetadataUpdate::builder()
1689            .title("Complete")
1690            .upload_type(UploadType::Dataset)
1691            .publication_date(publication_date)
1692            .description_html("<p>Complete</p>")
1693            .creators(vec![primary_creator.clone()])
1694            .creator(secondary_creator.clone())
1695            .access_right(AccessRight::Embargoed)
1696            .license("cc-by-4.0")
1697            .keywords(vec!["rust".into()])
1698            .keyword("zenodo")
1699            .related_identifiers(vec![related_identifier.clone()])
1700            .related_identifier(related_identifier_extra.clone())
1701            .notes("generated in tests")
1702            .version("1.2.3")
1703            .communities(vec![community.clone()])
1704            .community(extra_community.clone())
1705            .grants(vec![grant.clone()])
1706            .grant(extra_grant.clone())
1707            .extra(extra)
1708            .extra_field("source", json!("tarpaulin"))
1709            .build()
1710            .unwrap();
1711
1712        assert_eq!(metadata.publication_date, Some(publication_date));
1713        assert_eq!(metadata.creators, vec![primary_creator, secondary_creator]);
1714        assert_eq!(metadata.access_right, AccessRight::Embargoed);
1715        assert_eq!(metadata.license.as_deref(), Some("cc-by-4.0"));
1716        assert_eq!(metadata.keywords, vec!["rust", "zenodo"]);
1717        assert_eq!(
1718            metadata.related_identifiers,
1719            vec![related_identifier, related_identifier_extra]
1720        );
1721        assert_eq!(metadata.notes.as_deref(), Some("generated in tests"));
1722        assert_eq!(metadata.version.as_deref(), Some("1.2.3"));
1723        assert_eq!(metadata.communities, vec![community, extra_community]);
1724        assert_eq!(metadata.grants, vec![grant, extra_grant]);
1725        assert_eq!(metadata.extra.get("language"), Some(&json!("en")));
1726        assert_eq!(metadata.extra.get("source"), Some(&json!("tarpaulin")));
1727    }
1728
1729    #[test]
1730    fn nested_metadata_entry_builders_cover_common_construction_paths() {
1731        let creator = Creator::builder()
1732            .name("Doe, Jane")
1733            .affiliation("Zenodo")
1734            .orcid("0000-0000-0000-0001")
1735            .gnd("98765")
1736            .extra_field("department", json!("Research"))
1737            .build()
1738            .unwrap();
1739        let contributor = Contributor::builder()
1740            .name("Doe, John")
1741            .role("DataManager")
1742            .affiliation("Zenodo")
1743            .build()
1744            .unwrap();
1745        let subject = Subject::builder()
1746            .term("chemistry")
1747            .identifier("123")
1748            .scheme("custom")
1749            .build()
1750            .unwrap();
1751        let identifier = RecordIdentifier::builder()
1752            .identifier("10.5281/zenodo.42")
1753            .scheme("doi")
1754            .build()
1755            .unwrap();
1756        let date = RecordDate::builder()
1757            .date("2026-04-03")
1758            .date_type("Collected")
1759            .description("Sampling day")
1760            .build()
1761            .unwrap();
1762        let related = RelatedIdentifier::builder()
1763            .identifier("10.5281/zenodo.41")
1764            .relation("isVersionOf")
1765            .scheme("doi")
1766            .resource_type("dataset")
1767            .build()
1768            .unwrap();
1769        let community = CommunityRef::builder()
1770            .identifier("zenodo")
1771            .build()
1772            .unwrap();
1773        let grant = GrantRef::builder().id("grant-1").build().unwrap();
1774        let license = LicenseRef::builder()
1775            .id("cc-by-4.0")
1776            .title("CC BY 4.0")
1777            .build();
1778        let resource_type = ResourceType::builder()
1779            .type_("dataset")
1780            .subtype("image")
1781            .title("Dataset")
1782            .build();
1783
1784        assert_eq!(creator.name, "Doe, Jane");
1785        assert_eq!(contributor.type_, "DataManager");
1786        assert_eq!(subject.term, "chemistry");
1787        assert_eq!(identifier.scheme.as_deref(), Some("doi"));
1788        assert_eq!(date.type_.as_deref(), Some("Collected"));
1789        assert_eq!(related.resource_type.as_deref(), Some("dataset"));
1790        assert_eq!(community.identifier, "zenodo");
1791        assert_eq!(grant.id, "grant-1");
1792        assert_eq!(license.id.as_deref(), Some("cc-by-4.0"));
1793        assert_eq!(resource_type.type_.as_deref(), Some("dataset"));
1794    }
1795
1796    #[test]
1797    fn person_and_relation_metadata_builders_cover_full_surface() {
1798        let mut creator_extra = std::collections::BTreeMap::new();
1799        creator_extra.insert("department".into(), json!("Research"));
1800        let creator = Creator::builder()
1801            .name("Doe, Jane")
1802            .affiliation("Zenodo")
1803            .orcid("0000-0000-0000-0001")
1804            .gnd("98765")
1805            .extra(creator_extra.clone())
1806            .extra_field("lab", json!("Core"))
1807            .build()
1808            .unwrap();
1809        assert_eq!(Creator::named("Named Only").name, "Named Only");
1810        assert_eq!(creator.extra.get("department"), Some(&json!("Research")));
1811        assert_eq!(creator.extra.get("lab"), Some(&json!("Core")));
1812
1813        let mut contributor_extra = std::collections::BTreeMap::new();
1814        contributor_extra.insert("x".into(), json!(1));
1815        let contributor = Contributor::builder()
1816            .name("Doe, John")
1817            .role("Editor")
1818            .affiliation("Zenodo")
1819            .orcid("0000-0000-0000-0002")
1820            .gnd("12345")
1821            .extra(contributor_extra)
1822            .extra_field("y", json!(2))
1823            .build()
1824            .unwrap();
1825        assert_eq!(Contributor::new("Ada", "Supervisor").type_, "Supervisor");
1826        assert_eq!(contributor.extra.get("x"), Some(&json!(1)));
1827        assert_eq!(contributor.extra.get("y"), Some(&json!(2)));
1828
1829        let mut related_extra = std::collections::BTreeMap::new();
1830        related_extra.insert("strength".into(), json!("primary"));
1831        let related = RelatedIdentifier::builder()
1832            .identifier("10.5281/zenodo.1")
1833            .relation("isVersionOf")
1834            .scheme("doi")
1835            .resource_type("dataset")
1836            .extra(related_extra)
1837            .extra_field("note", json!("important"))
1838            .build()
1839            .unwrap();
1840        assert_eq!(
1841            RelatedIdentifier::new("10.5281/zenodo.2", "references").relation,
1842            "references"
1843        );
1844        assert_eq!(related.extra.get("strength"), Some(&json!("primary")));
1845        assert_eq!(related.extra.get("note"), Some(&json!("important")));
1846    }
1847
1848    #[test]
1849    fn classification_metadata_builders_cover_full_surface() {
1850        let mut subject_extra = std::collections::BTreeMap::new();
1851        subject_extra.insert("priority".into(), json!("high"));
1852        let subject = Subject::builder()
1853            .term("chemistry")
1854            .identifier("123")
1855            .scheme("custom")
1856            .extra(subject_extra)
1857            .extra_field("group", json!("A"))
1858            .build()
1859            .unwrap();
1860        assert_eq!(Subject::new("physics").term, "physics");
1861        assert_eq!(subject.extra.get("priority"), Some(&json!("high")));
1862        assert_eq!(subject.extra.get("group"), Some(&json!("A")));
1863
1864        let mut identifier_extra = std::collections::BTreeMap::new();
1865        identifier_extra.insert("kind".into(), json!("alternate"));
1866        let identifier = RecordIdentifier::builder()
1867            .identifier("10.5281/zenodo.42")
1868            .scheme("doi")
1869            .extra(identifier_extra)
1870            .extra_field("source", json!("Zenodo"))
1871            .build()
1872            .unwrap();
1873        assert_eq!(RecordIdentifier::new("ark:/123").identifier, "ark:/123");
1874        assert_eq!(identifier.extra.get("kind"), Some(&json!("alternate")));
1875        assert_eq!(identifier.extra.get("source"), Some(&json!("Zenodo")));
1876
1877        let mut date_extra = std::collections::BTreeMap::new();
1878        date_extra.insert("certainty".into(), json!("exact"));
1879        let date = RecordDate::builder()
1880            .date("2026-04-03")
1881            .date_type("Collected")
1882            .description("Sampling day")
1883            .extra(date_extra)
1884            .extra_field("timezone", json!("UTC"))
1885            .build()
1886            .unwrap();
1887        assert_eq!(RecordDate::new("2026-04-04").date, "2026-04-04");
1888        assert_eq!(date.extra.get("certainty"), Some(&json!("exact")));
1889        assert_eq!(date.extra.get("timezone"), Some(&json!("UTC")));
1890    }
1891
1892    #[test]
1893    fn reference_metadata_builders_cover_full_surface() {
1894        let mut community_extra = std::collections::BTreeMap::new();
1895        community_extra.insert("owner".into(), json!("zenodo"));
1896        let community = CommunityRef::builder()
1897            .identifier("zenodo")
1898            .extra(community_extra)
1899            .extra_field("scope", json!("public"))
1900            .build()
1901            .unwrap();
1902        assert_eq!(CommunityRef::new("sandbox").identifier, "sandbox");
1903        assert_eq!(community.extra.get("owner"), Some(&json!("zenodo")));
1904        assert_eq!(community.extra.get("scope"), Some(&json!("public")));
1905
1906        let mut grant_extra = std::collections::BTreeMap::new();
1907        grant_extra.insert("agency".into(), json!("EU"));
1908        let grant = GrantRef::builder()
1909            .id("grant-1")
1910            .extra(grant_extra)
1911            .extra_field("call", json!("Horizon"))
1912            .build()
1913            .unwrap();
1914        assert_eq!(GrantRef::new("grant-2").id, "grant-2");
1915        assert_eq!(grant.extra.get("agency"), Some(&json!("EU")));
1916        assert_eq!(grant.extra.get("call"), Some(&json!("Horizon")));
1917
1918        let mut license_extra = std::collections::BTreeMap::new();
1919        license_extra.insert("jurisdiction".into(), json!("EU"));
1920        let license = LicenseRef::builder()
1921            .id("cc-by-4.0")
1922            .title("CC BY 4.0")
1923            .extra(license_extra)
1924            .extra_field("version", json!("4.0"))
1925            .build();
1926        assert_eq!(LicenseRef::new("mit").id.as_deref(), Some("mit"));
1927        assert_eq!(license.extra.get("jurisdiction"), Some(&json!("EU")));
1928        assert_eq!(license.extra.get("version"), Some(&json!("4.0")));
1929
1930        let mut resource_type_extra = std::collections::BTreeMap::new();
1931        resource_type_extra.insert("family".into(), json!("research-data"));
1932        let resource_type = ResourceType::builder()
1933            .type_("dataset")
1934            .subtype("image")
1935            .title("Dataset")
1936            .extra(resource_type_extra)
1937            .extra_field("display", json!("Data set"))
1938            .build();
1939        assert_eq!(
1940            ResourceType::new("software").type_.as_deref(),
1941            Some("software")
1942        );
1943        assert_eq!(
1944            resource_type.extra.get("family"),
1945            Some(&json!("research-data"))
1946        );
1947        assert_eq!(resource_type.extra.get("display"), Some(&json!("Data set")));
1948    }
1949
1950    #[test]
1951    fn deposit_metadata_builder_shortcuts_are_exercised() {
1952        let metadata = DepositMetadataUpdate::builder()
1953            .title("Example")
1954            .upload_type(UploadType::Software)
1955            .description_html("<p>Example</p>")
1956            .creator_named("Doe, Jane")
1957            .access_right(AccessRight::Open)
1958            .community_identifier("zenodo")
1959            .grant_id("grant-1")
1960            .build()
1961            .unwrap();
1962
1963        assert_eq!(metadata.creators[0].name, "Doe, Jane");
1964        assert_eq!(metadata.communities[0].identifier, "zenodo");
1965        assert_eq!(metadata.grants[0].id, "grant-1");
1966    }
1967
1968    #[test]
1969    fn nested_metadata_entry_builders_require_mandatory_fields() {
1970        assert_eq!(
1971            Creator::builder().build().unwrap_err(),
1972            MetadataEntryBuildError::MissingField {
1973                entry: "Creator",
1974                field: "name",
1975            }
1976        );
1977        assert_eq!(
1978            Contributor::builder().name("Doe").build().unwrap_err(),
1979            MetadataEntryBuildError::MissingField {
1980                entry: "Contributor",
1981                field: "role",
1982            }
1983        );
1984        assert_eq!(
1985            RelatedIdentifier::builder()
1986                .identifier("10.5281/zenodo.1")
1987                .build()
1988                .unwrap_err(),
1989            MetadataEntryBuildError::MissingField {
1990                entry: "RelatedIdentifier",
1991                field: "relation",
1992            }
1993        );
1994    }
1995
1996    #[test]
1997    fn enums_preserve_unknown_values() {
1998        let upload: UploadType = serde_json::from_str("\"posterish\"").unwrap();
1999        let access: AccessRight = serde_json::from_str("\"members-only\"").unwrap();
2000
2001        assert_eq!(serde_json::to_string(&upload).unwrap(), "\"posterish\"");
2002        assert_eq!(serde_json::to_string(&access).unwrap(), "\"members-only\"");
2003    }
2004
2005    #[test]
2006    fn known_enum_values_round_trip() {
2007        let upload = serde_json::to_string(&UploadType::Dataset).unwrap();
2008        let access = serde_json::to_string(&AccessRight::Open).unwrap();
2009        let metadata = RecordMetadata::default();
2010
2011        assert_eq!(upload, "\"dataset\"");
2012        assert_eq!(access, "\"open\"");
2013        assert!(metadata.creators.is_empty());
2014    }
2015
2016    #[test]
2017    fn published_record_metadata_deserializes_richer_typed_fields() {
2018        let metadata: RecordMetadata = serde_json::from_str(
2019            r#"{
2020                "title": "Rich record",
2021                "publication_date": "2026-04-03",
2022                "description": "<p>Rich</p>",
2023                "creators": [{ "name": "Doe, Jane" }],
2024                "contributors": [{ "name": "Doe, John", "type": "Editor" }],
2025                "keywords": ["rust", "zenodo"],
2026                "references": ["Doe J. Example."],
2027                "communities": [{ "identifier": "zenodo" }],
2028                "grants": [{ "id": "777541" }],
2029                "subjects": [{ "term": "Metadata", "scheme": "custom" }],
2030                "identifiers": [{ "identifier": "sha256:abc", "scheme": "sha256" }],
2031                "alternate_identifiers": [{ "identifier": "arXiv:1234.5678", "scheme": "arxiv" }],
2032                "dates": [{ "date": "2026-04-03", "type": "Collected" }],
2033                "related_identifiers": [{ "identifier": "10.5281/zenodo.42", "relation": "isSupplementTo" }],
2034                "resource_type": { "type": "dataset", "title": "Dataset" },
2035                "access_right": "open",
2036                "access_conditions": "By request",
2037                "license": { "id": "cc-by-4.0", "title": "Creative Commons Attribution 4.0 International" },
2038                "publisher": "Zenodo",
2039                "language": "eng",
2040                "sizes": ["1 file"],
2041                "formats": ["application/gzip"],
2042                "notes": "Some notes",
2043                "version": "1.2.3",
2044                "journal_title": "Journal of Rust",
2045                "journal_volume": "12",
2046                "journal_issue": "3",
2047                "journal_pages": "10-20",
2048                "conference_title": "RustConf",
2049                "conference_acronym": "RC",
2050                "conference_dates": "2026-04-03",
2051                "conference_place": "Rome, Italy",
2052                "conference_url": "https://example.org/conf",
2053                "conference_session": "A",
2054                "conference_session_part": "1",
2055                "imprint_publisher": "Example Press",
2056                "imprint_isbn": "978-1-234",
2057                "imprint_place": "Rome, Italy",
2058                "partof_title": "Collected Works",
2059                "partof_pages": "44-50",
2060                "thesis_supervisors": [{ "name": "Professor, Ada" }],
2061                "thesis_university": "Example University",
2062                "relations": {
2063                    "version": [{ "index": 2, "count": 4, "is_last": false }]
2064                }
2065            }"#,
2066        )
2067        .unwrap();
2068
2069        assert_eq!(
2070            metadata.contributors,
2071            vec![Contributor {
2072                name: "Doe, John".into(),
2073                type_: "Editor".into(),
2074                affiliation: None,
2075                orcid: None,
2076                gnd: None,
2077                extra: std::collections::BTreeMap::default(),
2078            }]
2079        );
2080        assert_eq!(
2081            metadata.subjects,
2082            vec![Subject {
2083                term: "Metadata".into(),
2084                identifier: None,
2085                scheme: Some("custom".into()),
2086                extra: std::collections::BTreeMap::default(),
2087            }]
2088        );
2089        assert_eq!(metadata.access_right, Some(AccessRight::Open));
2090        assert_eq!(metadata.publisher.as_deref(), Some("Zenodo"));
2091        assert_eq!(
2092            metadata.relations,
2093            Some(RecordRelations {
2094                version: vec![RecordVersionRelation {
2095                    index: Some(2),
2096                    count: Some(4),
2097                    is_last: Some(false),
2098                    extra: std::collections::BTreeMap::default(),
2099                }],
2100                extra: std::collections::BTreeMap::default(),
2101            })
2102        );
2103        assert_eq!(
2104            metadata.thesis_university.as_deref(),
2105            Some("Example University")
2106        );
2107    }
2108
2109    #[test]
2110    fn published_record_metadata_accepts_community_id_alias() {
2111        let metadata: RecordMetadata = serde_json::from_str(
2112            r#"{
2113                "title": "Rich record",
2114                "creators": [{ "name": "Doe, Jane" }],
2115                "communities": [
2116                    { "id": "earth-metabolome", "title": "Earth Metabolome" }
2117                ]
2118            }"#,
2119        )
2120        .unwrap();
2121
2122        assert_eq!(metadata.communities.len(), 1);
2123        assert_eq!(metadata.communities[0].identifier, "earth-metabolome");
2124        assert_eq!(
2125            metadata.communities[0].extra.get("title"),
2126            Some(&json!("Earth Metabolome"))
2127        );
2128    }
2129
2130    #[test]
2131    fn record_version_relations_accept_integer_like_numeric_shapes() {
2132        let metadata: RecordMetadata = serde_json::from_str(
2133            r#"{
2134                "title": "Rich record",
2135                "creators": [{ "name": "Doe, Jane" }],
2136                "relations": {
2137                    "version": [{ "index": "2", "count": 4.0, "is_last": true }]
2138                }
2139            }"#,
2140        )
2141        .unwrap();
2142
2143        assert_eq!(
2144            metadata.relations,
2145            Some(RecordRelations {
2146                version: vec![RecordVersionRelation {
2147                    index: Some(2),
2148                    count: Some(4),
2149                    is_last: Some(true),
2150                    extra: std::collections::BTreeMap::default(),
2151                }],
2152                extra: std::collections::BTreeMap::default(),
2153            })
2154        );
2155    }
2156}