Skip to main content

figshare_rs/
metadata.rs

1//! Article metadata builders and defined type helpers.
2
3use std::collections::BTreeMap;
4
5use serde::de::{self, Visitor};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::{Map, Value};
8use thiserror::Error;
9
10use crate::ids::{CategoryId, Doi, LicenseId};
11
12/// Typed article kind used by Figshare payloads and search filters.
13#[derive(Clone, Debug, PartialEq, Eq, Hash)]
14#[non_exhaustive]
15pub enum DefinedType {
16    /// Figure.
17    Figure,
18    /// Dataset.
19    Dataset,
20    /// Media.
21    Media,
22    /// Poster.
23    Poster,
24    /// Journal contribution.
25    JournalContribution,
26    /// Presentation.
27    Presentation,
28    /// Thesis.
29    Thesis,
30    /// Software.
31    Software,
32    /// Online resource.
33    OnlineResource,
34    /// Preprint.
35    Preprint,
36    /// Book.
37    Book,
38    /// Conference contribution.
39    ConferenceContribution,
40    /// Chapter.
41    Chapter,
42    /// Peer review.
43    PeerReview,
44    /// Educational resource.
45    EducationalResource,
46    /// Report.
47    Report,
48    /// Standard.
49    Standard,
50    /// Composition.
51    Composition,
52    /// Funding.
53    Funding,
54    /// Physical object.
55    PhysicalObject,
56    /// Data management plan.
57    DataManagementPlan,
58    /// Workflow.
59    Workflow,
60    /// Monograph.
61    Monograph,
62    /// Performance.
63    Performance,
64    /// Event.
65    Event,
66    /// Service.
67    Service,
68    /// Model.
69    Model,
70    /// Unknown server value preserved as-is.
71    Unknown(String),
72}
73
74impl DefinedType {
75    /// Returns the string form used by create and update payloads.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use figshare_rs::DefinedType;
81    ///
82    /// assert_eq!(DefinedType::Software.api_name(), "software");
83    /// assert_eq!(
84    ///     DefinedType::JournalContribution.api_name(),
85    ///     "journal contribution"
86    /// );
87    /// ```
88    #[must_use]
89    pub fn api_name(&self) -> &str {
90        match self {
91            Self::Figure => "figure",
92            Self::Dataset => "dataset",
93            Self::Media => "media",
94            Self::Poster => "poster",
95            Self::JournalContribution => "journal contribution",
96            Self::Presentation => "presentation",
97            Self::Thesis => "thesis",
98            Self::Software => "software",
99            Self::OnlineResource => "online resource",
100            Self::Preprint => "preprint",
101            Self::Book => "book",
102            Self::ConferenceContribution => "conference contribution",
103            Self::Chapter => "chapter",
104            Self::PeerReview => "peer review",
105            Self::EducationalResource => "educational resource",
106            Self::Report => "report",
107            Self::Standard => "standard",
108            Self::Composition => "composition",
109            Self::Funding => "funding",
110            Self::PhysicalObject => "physical object",
111            Self::DataManagementPlan => "data management plan",
112            Self::Workflow => "workflow",
113            Self::Monograph => "monograph",
114            Self::Performance => "performance",
115            Self::Event => "event",
116            Self::Service => "service",
117            Self::Model => "model",
118            Self::Unknown(value) => value.as_str(),
119        }
120    }
121
122    /// Returns the numeric form used by some list/search filters and presenters.
123    #[must_use]
124    pub fn api_id(&self) -> Option<u64> {
125        match self {
126            Self::Figure => Some(1),
127            Self::Dataset => Some(3),
128            Self::Media => Some(2),
129            Self::Poster => Some(5),
130            Self::JournalContribution => Some(6),
131            Self::Presentation => Some(7),
132            Self::Thesis => Some(8),
133            Self::Software => Some(9),
134            Self::OnlineResource => Some(11),
135            Self::Preprint => Some(12),
136            Self::Book => Some(13),
137            Self::ConferenceContribution => Some(14),
138            Self::Chapter => Some(15),
139            Self::PeerReview => Some(16),
140            Self::EducationalResource => Some(17),
141            Self::Report => Some(18),
142            Self::Standard => Some(19),
143            Self::Composition => Some(20),
144            Self::Funding => Some(21),
145            Self::PhysicalObject => Some(22),
146            Self::DataManagementPlan => Some(23),
147            Self::Workflow => Some(24),
148            Self::Monograph => Some(25),
149            Self::Performance => Some(26),
150            Self::Event => Some(27),
151            Self::Service => Some(28),
152            Self::Model => Some(29),
153            Self::Unknown(value) => value.parse().ok(),
154        }
155    }
156
157    /// Converts the integer representation used by presenter payloads into a
158    /// typed [`DefinedType`].
159    #[must_use]
160    pub fn from_api_id(id: u64) -> Self {
161        match id {
162            1 => Self::Figure,
163            2 => Self::Media,
164            3 => Self::Dataset,
165            5 => Self::Poster,
166            6 => Self::JournalContribution,
167            7 => Self::Presentation,
168            8 => Self::Thesis,
169            9 => Self::Software,
170            11 => Self::OnlineResource,
171            12 => Self::Preprint,
172            13 => Self::Book,
173            14 => Self::ConferenceContribution,
174            15 => Self::Chapter,
175            16 => Self::PeerReview,
176            17 => Self::EducationalResource,
177            18 => Self::Report,
178            19 => Self::Standard,
179            20 => Self::Composition,
180            21 => Self::Funding,
181            22 => Self::PhysicalObject,
182            23 => Self::DataManagementPlan,
183            24 => Self::Workflow,
184            25 => Self::Monograph,
185            26 => Self::Performance,
186            27 => Self::Event,
187            28 => Self::Service,
188            29 => Self::Model,
189            other => Self::Unknown(other.to_string()),
190        }
191    }
192
193    /// Converts the string representation used by create/update payloads into a
194    /// typed [`DefinedType`].
195    #[must_use]
196    pub fn from_api_name(value: impl Into<String>) -> Self {
197        let value = value.into();
198        let normalized = value.to_ascii_lowercase().replace('_', " ");
199        match normalized.as_str() {
200            "figure" => Self::Figure,
201            "media" => Self::Media,
202            "dataset" => Self::Dataset,
203            "poster" => Self::Poster,
204            "paper" | "journal contribution" => Self::JournalContribution,
205            "presentation" => Self::Presentation,
206            "thesis" => Self::Thesis,
207            "code" | "software" => Self::Software,
208            "metadata" | "online resource" => Self::OnlineResource,
209            "preprint" => Self::Preprint,
210            "book" => Self::Book,
211            "conference contribution" => Self::ConferenceContribution,
212            "chapter" => Self::Chapter,
213            "peer review" => Self::PeerReview,
214            "educational resource" => Self::EducationalResource,
215            "report" => Self::Report,
216            "standard" => Self::Standard,
217            "composition" => Self::Composition,
218            "funding" => Self::Funding,
219            "physical object" => Self::PhysicalObject,
220            "data management plan" => Self::DataManagementPlan,
221            "workflow" => Self::Workflow,
222            "monograph" => Self::Monograph,
223            "performance" => Self::Performance,
224            "event" => Self::Event,
225            "service" => Self::Service,
226            "model" => Self::Model,
227            _ => Self::Unknown(value),
228        }
229    }
230}
231
232impl Serialize for DefinedType {
233    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
234    where
235        S: Serializer,
236    {
237        serializer.serialize_str(self.api_name())
238    }
239}
240
241impl<'de> Deserialize<'de> for DefinedType {
242    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
243    where
244        D: Deserializer<'de>,
245    {
246        struct DefinedTypeVisitor;
247
248        impl Visitor<'_> for DefinedTypeVisitor {
249            type Value = DefinedType;
250
251            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252                formatter.write_str("a Figshare defined_type string or integer")
253            }
254
255            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
256                Ok(DefinedType::from_api_id(value))
257            }
258
259            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
260            where
261                E: de::Error,
262            {
263                let value = u64::try_from(value).map_err(E::custom)?;
264                Ok(DefinedType::from_api_id(value))
265            }
266
267            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
268            where
269                E: de::Error,
270            {
271                if !value.is_finite() || value.fract() != 0.0 || value < 0.0 {
272                    return Err(E::custom("expected an integer-like defined_type value"));
273                }
274
275                let value = value.to_string().parse::<u64>().map_err(E::custom)?;
276                Ok(DefinedType::from_api_id(value))
277            }
278
279            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
280                Ok(DefinedType::from_api_name(value))
281            }
282
283            fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
284                Ok(DefinedType::from_api_name(value))
285            }
286        }
287
288        deserializer.deserialize_any(DefinedTypeVisitor)
289    }
290}
291
292/// Reference to an author in create/update payloads.
293#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(untagged)]
295pub enum AuthorReference {
296    /// Reference an existing author by ID.
297    Id {
298        /// Existing author identifier.
299        id: u64,
300    },
301    /// Reference an author by free-form display name.
302    Name {
303        /// Author display name.
304        name: String,
305    },
306}
307
308impl AuthorReference {
309    /// Creates an ID-based author reference.
310    #[must_use]
311    pub fn id(id: u64) -> Self {
312        Self::Id { id }
313    }
314
315    /// Creates a name-based author reference.
316    #[must_use]
317    pub fn name(name: impl Into<String>) -> Self {
318        Self::Name { name: name.into() }
319    }
320}
321
322/// Builder errors for [`ArticleMetadata`].
323#[derive(Debug, Error, PartialEq, Eq)]
324pub enum ArticleMetadataBuildError {
325    /// The title field is required.
326    #[error("missing required field: title")]
327    MissingTitle,
328    /// The defined type field is required.
329    #[error("missing required field: defined_type")]
330    MissingDefinedType,
331}
332
333/// High-level create/update payload used by workflow helpers.
334#[derive(Clone, Debug, PartialEq)]
335pub struct ArticleMetadata {
336    /// Title of the article.
337    pub title: String,
338    /// Optional description.
339    pub description: Option<String>,
340    /// Required item type.
341    pub defined_type: DefinedType,
342    /// Optional tags.
343    pub tags: Vec<String>,
344    /// Optional keywords.
345    pub keywords: Vec<String>,
346    /// Optional references.
347    pub references: Vec<String>,
348    /// Optional category identifiers.
349    pub categories: Vec<CategoryId>,
350    /// Optional author references.
351    pub authors: Vec<AuthorReference>,
352    /// Optional custom fields.
353    pub custom_fields: BTreeMap<String, Value>,
354    /// Optional funding string.
355    pub funding: Option<String>,
356    /// Optional license identifier.
357    pub license: Option<LicenseId>,
358    /// Optional pre-reserved DOI.
359    pub doi: Option<Doi>,
360    /// Optional related resource DOI.
361    pub resource_doi: Option<String>,
362    /// Optional related resource title.
363    pub resource_title: Option<String>,
364}
365
366impl ArticleMetadata {
367    /// Starts building article metadata.
368    ///
369    /// # Examples
370    ///
371    /// ```
372    /// use figshare_rs::{ArticleMetadata, DefinedType};
373    ///
374    /// let metadata = ArticleMetadata::builder()
375    ///     .title("Example dataset")
376    ///     .defined_type(DefinedType::Dataset)
377    ///     .author_named("Doe, Jane")
378    ///     .tag("example")
379    ///     .build()?;
380    ///
381    /// assert_eq!(metadata.title, "Example dataset");
382    /// assert_eq!(metadata.defined_type, DefinedType::Dataset);
383    /// assert_eq!(metadata.tags, vec!["example".to_owned()]);
384    /// assert_eq!(metadata.authors.len(), 1);
385    /// # Ok::<(), figshare_rs::ArticleMetadataBuildError>(())
386    /// ```
387    #[must_use]
388    pub fn builder() -> ArticleMetadataBuilder {
389        ArticleMetadataBuilder::default()
390    }
391
392    pub(crate) fn to_payload(&self) -> Value {
393        let mut object = Map::new();
394        object.insert("title".into(), Value::String(self.title.clone()));
395        object.insert(
396            "defined_type".into(),
397            Value::String(self.defined_type.api_name().to_owned()),
398        );
399
400        if let Some(description) = &self.description {
401            object.insert("description".into(), Value::String(description.clone()));
402        }
403        if !self.tags.is_empty() {
404            object.insert(
405                "tags".into(),
406                Value::Array(self.tags.iter().cloned().map(Value::String).collect()),
407            );
408        }
409        if !self.keywords.is_empty() {
410            object.insert(
411                "keywords".into(),
412                Value::Array(self.keywords.iter().cloned().map(Value::String).collect()),
413            );
414        }
415        if !self.references.is_empty() {
416            object.insert(
417                "references".into(),
418                Value::Array(self.references.iter().cloned().map(Value::String).collect()),
419            );
420        }
421        if !self.categories.is_empty() {
422            object.insert(
423                "categories".into(),
424                Value::Array(
425                    self.categories
426                        .iter()
427                        .map(|category| Value::from(category.0))
428                        .collect(),
429                ),
430            );
431        }
432        if !self.authors.is_empty() {
433            object.insert(
434                "authors".into(),
435                Value::Array(
436                    self.authors
437                        .iter()
438                        .map(|author| match author {
439                            AuthorReference::Id { id } => {
440                                let mut author = Map::new();
441                                author.insert("id".into(), Value::from(*id));
442                                Value::Object(author)
443                            }
444                            AuthorReference::Name { name } => {
445                                let mut author = Map::new();
446                                author.insert("name".into(), Value::String(name.clone()));
447                                Value::Object(author)
448                            }
449                        })
450                        .collect(),
451                ),
452            );
453        }
454        if !self.custom_fields.is_empty() {
455            object.insert(
456                "custom_fields".into(),
457                Value::Object(self.custom_fields.clone().into_iter().collect()),
458            );
459        }
460        if let Some(funding) = &self.funding {
461            object.insert("funding".into(), Value::String(funding.clone()));
462        }
463        if let Some(license) = self.license {
464            object.insert("license".into(), Value::from(license.0));
465        }
466        if let Some(doi) = &self.doi {
467            object.insert("doi".into(), Value::String(doi.to_string()));
468        }
469        if let Some(resource_doi) = &self.resource_doi {
470            object.insert("resource_doi".into(), Value::String(resource_doi.clone()));
471        }
472        if let Some(resource_title) = &self.resource_title {
473            object.insert(
474                "resource_title".into(),
475                Value::String(resource_title.clone()),
476            );
477        }
478
479        Value::Object(object)
480    }
481}
482
483/// Builder for [`ArticleMetadata`].
484#[derive(Clone, Debug, Default)]
485pub struct ArticleMetadataBuilder {
486    title: Option<String>,
487    description: Option<String>,
488    defined_type: Option<DefinedType>,
489    tags: Vec<String>,
490    keywords: Vec<String>,
491    references: Vec<String>,
492    categories: Vec<CategoryId>,
493    authors: Vec<AuthorReference>,
494    custom_fields: BTreeMap<String, Value>,
495    funding: Option<String>,
496    license: Option<LicenseId>,
497    doi: Option<Doi>,
498    resource_doi: Option<String>,
499    resource_title: Option<String>,
500}
501
502impl ArticleMetadataBuilder {
503    /// Sets the title.
504    #[must_use]
505    pub fn title(mut self, title: impl Into<String>) -> Self {
506        self.title = Some(title.into());
507        self
508    }
509
510    /// Sets the description.
511    #[must_use]
512    pub fn description(mut self, description: impl Into<String>) -> Self {
513        self.description = Some(description.into());
514        self
515    }
516
517    /// Sets the defined type.
518    #[must_use]
519    pub fn defined_type(mut self, defined_type: DefinedType) -> Self {
520        self.defined_type = Some(defined_type);
521        self
522    }
523
524    /// Adds one tag.
525    #[must_use]
526    pub fn tag(mut self, tag: impl Into<String>) -> Self {
527        self.tags.push(tag.into());
528        self
529    }
530
531    /// Adds one keyword.
532    #[must_use]
533    pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
534        self.keywords.push(keyword.into());
535        self
536    }
537
538    /// Adds one reference URL or citation.
539    #[must_use]
540    pub fn reference(mut self, reference: impl Into<String>) -> Self {
541        self.references.push(reference.into());
542        self
543    }
544
545    /// Adds one category ID.
546    #[must_use]
547    pub fn category_id(mut self, category: impl Into<CategoryId>) -> Self {
548        self.categories.push(category.into());
549        self
550    }
551
552    /// Adds one author reference.
553    #[must_use]
554    pub fn author(mut self, author: AuthorReference) -> Self {
555        self.authors.push(author);
556        self
557    }
558
559    /// Adds one author by ID.
560    #[must_use]
561    pub fn author_id(self, author_id: u64) -> Self {
562        self.author(AuthorReference::id(author_id))
563    }
564
565    /// Adds one author by name.
566    #[must_use]
567    pub fn author_named(self, name: impl Into<String>) -> Self {
568        self.author(AuthorReference::name(name))
569    }
570
571    /// Adds one string custom field.
572    #[must_use]
573    pub fn custom_field_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
574        self.custom_fields
575            .insert(name.into(), Value::String(value.into()));
576        self
577    }
578
579    /// Adds one raw JSON custom field.
580    #[must_use]
581    pub fn custom_field_json(mut self, name: impl Into<String>, value: Value) -> Self {
582        self.custom_fields.insert(name.into(), value);
583        self
584    }
585
586    /// Sets the funding field.
587    #[must_use]
588    pub fn funding(mut self, funding: impl Into<String>) -> Self {
589        self.funding = Some(funding.into());
590        self
591    }
592
593    /// Sets the license ID.
594    #[must_use]
595    pub fn license_id(mut self, license: impl Into<LicenseId>) -> Self {
596        self.license = Some(license.into());
597        self
598    }
599
600    /// Sets the DOI.
601    #[must_use]
602    pub fn doi(mut self, doi: Doi) -> Self {
603        self.doi = Some(doi);
604        self
605    }
606
607    /// Sets the related resource DOI.
608    #[must_use]
609    pub fn resource_doi(mut self, resource_doi: impl Into<String>) -> Self {
610        self.resource_doi = Some(resource_doi.into());
611        self
612    }
613
614    /// Sets the related resource title.
615    #[must_use]
616    pub fn resource_title(mut self, resource_title: impl Into<String>) -> Self {
617        self.resource_title = Some(resource_title.into());
618        self
619    }
620
621    /// Finishes the builder.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if required fields are missing.
626    pub fn build(self) -> Result<ArticleMetadata, ArticleMetadataBuildError> {
627        let title = self.title.ok_or(ArticleMetadataBuildError::MissingTitle)?;
628        let defined_type = self
629            .defined_type
630            .ok_or(ArticleMetadataBuildError::MissingDefinedType)?;
631
632        Ok(ArticleMetadata {
633            title,
634            description: self.description,
635            defined_type,
636            tags: self.tags,
637            keywords: self.keywords,
638            references: self.references,
639            categories: self.categories,
640            authors: self.authors,
641            custom_fields: self.custom_fields,
642            funding: self.funding,
643            license: self.license,
644            doi: self.doi,
645            resource_doi: self.resource_doi,
646            resource_title: self.resource_title,
647        })
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use serde_json::json;
654
655    use super::{ArticleMetadata, ArticleMetadataBuildError, AuthorReference, DefinedType};
656    use crate::ids::Doi;
657
658    #[test]
659    fn defined_type_accepts_strings_and_ids() {
660        let dataset_from_name: DefinedType = serde_json::from_str("\"dataset\"").unwrap();
661        let dataset_from_id: DefinedType = serde_json::from_str("3").unwrap();
662
663        assert_eq!(dataset_from_name, DefinedType::Dataset);
664        assert_eq!(dataset_from_id, DefinedType::Dataset);
665        assert_eq!(
666            serde_json::to_string(&DefinedType::Dataset).unwrap(),
667            "\"dataset\""
668        );
669    }
670
671    #[test]
672    fn defined_type_round_trips_all_current_api_variants() {
673        let cases = [
674            (DefinedType::Figure, "figure", 1),
675            (DefinedType::Media, "media", 2),
676            (DefinedType::Dataset, "dataset", 3),
677            (DefinedType::Poster, "poster", 5),
678            (DefinedType::JournalContribution, "journal contribution", 6),
679            (DefinedType::Presentation, "presentation", 7),
680            (DefinedType::Thesis, "thesis", 8),
681            (DefinedType::Software, "software", 9),
682            (DefinedType::OnlineResource, "online resource", 11),
683            (DefinedType::Preprint, "preprint", 12),
684            (DefinedType::Book, "book", 13),
685            (
686                DefinedType::ConferenceContribution,
687                "conference contribution",
688                14,
689            ),
690            (DefinedType::Chapter, "chapter", 15),
691            (DefinedType::PeerReview, "peer review", 16),
692            (DefinedType::EducationalResource, "educational resource", 17),
693            (DefinedType::Report, "report", 18),
694            (DefinedType::Standard, "standard", 19),
695            (DefinedType::Composition, "composition", 20),
696            (DefinedType::Funding, "funding", 21),
697            (DefinedType::PhysicalObject, "physical object", 22),
698            (DefinedType::DataManagementPlan, "data management plan", 23),
699            (DefinedType::Workflow, "workflow", 24),
700            (DefinedType::Monograph, "monograph", 25),
701            (DefinedType::Performance, "performance", 26),
702            (DefinedType::Event, "event", 27),
703            (DefinedType::Service, "service", 28),
704            (DefinedType::Model, "model", 29),
705        ];
706
707        for (defined_type, api_name, api_id) in cases {
708            assert_eq!(defined_type.api_name(), api_name);
709            assert_eq!(defined_type.api_id(), Some(api_id));
710            assert_eq!(DefinedType::from_api_name(api_name), defined_type);
711            assert_eq!(DefinedType::from_api_id(api_id), defined_type);
712        }
713    }
714
715    #[test]
716    fn defined_type_aliases_and_unknown_values_are_preserved() {
717        assert_eq!(
718            DefinedType::from_api_name("paper"),
719            DefinedType::JournalContribution
720        );
721        assert_eq!(DefinedType::from_api_name("code"), DefinedType::Software);
722        assert_eq!(
723            DefinedType::from_api_name("metadata"),
724            DefinedType::OnlineResource
725        );
726        assert_eq!(
727            DefinedType::from_api_name("custom widget"),
728            DefinedType::Unknown("custom widget".into())
729        );
730        assert_eq!(DefinedType::Unknown("31".into()).api_id(), Some(31));
731        assert_eq!(
732            DefinedType::from_api_id(31),
733            DefinedType::Unknown("31".into())
734        );
735    }
736
737    #[test]
738    fn defined_type_rejects_invalid_numeric_shapes() {
739        assert!(serde_json::from_str::<DefinedType>("-1").is_err());
740        assert!(serde_json::from_str::<DefinedType>("1.5").is_err());
741    }
742
743    #[test]
744    fn metadata_builder_requires_title_and_defined_type() {
745        assert_eq!(
746            ArticleMetadata::builder().build().unwrap_err(),
747            ArticleMetadataBuildError::MissingTitle
748        );
749        assert_eq!(
750            ArticleMetadata::builder().title("x").build().unwrap_err(),
751            ArticleMetadataBuildError::MissingDefinedType
752        );
753    }
754
755    #[test]
756    fn author_reference_helpers_cover_id_and_name_constructors() {
757        assert_eq!(AuthorReference::id(7), AuthorReference::Id { id: 7 });
758        assert_eq!(
759            AuthorReference::name("Doe, Jane"),
760            AuthorReference::Name {
761                name: "Doe, Jane".into()
762            }
763        );
764    }
765
766    #[test]
767    fn metadata_builder_serializes_expected_payload() {
768        let metadata = ArticleMetadata::builder()
769            .title("Example")
770            .defined_type(DefinedType::Dataset)
771            .description("Description")
772            .tag("data")
773            .keyword("science")
774            .reference("https://example.com")
775            .category_id(3)
776            .author(AuthorReference::id(7))
777            .author_named("Doe, Jane")
778            .custom_field_text("location", "Amsterdam")
779            .license_id(1)
780            .resource_doi("10.1234/example")
781            .build()
782            .unwrap();
783
784        let payload = metadata.to_payload();
785        assert_eq!(payload["title"], "Example");
786        assert_eq!(payload["defined_type"], "dataset");
787        assert_eq!(payload["categories"][0], 3);
788        assert_eq!(payload["authors"][0]["id"], 7);
789        assert_eq!(payload["authors"][1]["name"], "Doe, Jane");
790        assert_eq!(payload["custom_fields"]["location"], "Amsterdam");
791    }
792
793    #[test]
794    fn metadata_builder_populates_every_optional_field() {
795        let metadata = ArticleMetadata::builder()
796            .title("Example dataset")
797            .description("Long-form description")
798            .defined_type(DefinedType::Dataset)
799            .tag("alpha")
800            .tag("beta")
801            .keyword("science")
802            .keyword("open-data")
803            .reference("https://example.com/reference")
804            .category_id(11)
805            .category_id(12)
806            .author_id(3)
807            .author_named("Doe, Jane")
808            .custom_field_text("campus", "Pisa")
809            .custom_field_json("metrics", json!({ "downloads": 42 }))
810            .funding("Grant-42")
811            .license_id(1)
812            .doi(Doi::new("10.6084/m9.figshare.999").unwrap())
813            .resource_doi("10.1000/example")
814            .resource_title("Related resource")
815            .build()
816            .unwrap();
817
818        assert_eq!(metadata.title, "Example dataset");
819        assert_eq!(
820            metadata.description.as_deref(),
821            Some("Long-form description")
822        );
823        assert_eq!(metadata.defined_type, DefinedType::Dataset);
824        assert_eq!(metadata.tags, vec!["alpha", "beta"]);
825        assert_eq!(metadata.keywords, vec!["science", "open-data"]);
826        assert_eq!(metadata.references, vec!["https://example.com/reference"]);
827        assert_eq!(metadata.categories.len(), 2);
828        assert_eq!(metadata.authors.len(), 2);
829        assert_eq!(metadata.funding.as_deref(), Some("Grant-42"));
830        assert_eq!(metadata.license.unwrap().0, 1);
831        assert_eq!(
832            metadata.doi.as_ref().map(Doi::as_str),
833            Some("10.6084/m9.figshare.999")
834        );
835        assert_eq!(metadata.resource_doi.as_deref(), Some("10.1000/example"));
836        assert_eq!(metadata.resource_title.as_deref(), Some("Related resource"));
837
838        let payload = metadata.to_payload();
839        assert_eq!(payload["description"], "Long-form description");
840        assert_eq!(payload["tags"], json!(["alpha", "beta"]));
841        assert_eq!(payload["keywords"], json!(["science", "open-data"]));
842        assert_eq!(
843            payload["references"],
844            json!(["https://example.com/reference"])
845        );
846        assert_eq!(payload["categories"], json!([11, 12]));
847        assert_eq!(payload["authors"][0]["id"], 3);
848        assert_eq!(payload["authors"][1]["name"], "Doe, Jane");
849        assert_eq!(payload["custom_fields"]["campus"], "Pisa");
850        assert_eq!(payload["custom_fields"]["metrics"]["downloads"], 42);
851        assert_eq!(payload["funding"], "Grant-42");
852        assert_eq!(payload["license"], 1);
853        assert_eq!(payload["doi"], "10.6084/m9.figshare.999");
854        assert_eq!(payload["resource_doi"], "10.1000/example");
855        assert_eq!(payload["resource_title"], "Related resource");
856    }
857}