Skip to main content

zenodo_rs/
records.rs

1//! Published-record search, retrieval, and latest-version helpers.
2//!
3//! Use this module when you want to work with Zenodo's public record surface:
4//!
5//! - [`RecordQuery`] and [`RecordQueryBuilder`] for search
6//! - [`RecordSelector`] for choosing a record by ID or DOI
7//! - [`ArtifactSelector`] for naming a downloadable file or archive
8//!
9//! Most consumers start here for DOI lookup, latest-version resolution, and
10//! artifact-oriented read flows.
11
12use serde::Deserialize;
13use url::Url;
14
15use crate::client::ZenodoClient;
16use crate::error::ZenodoError;
17use crate::ids::{Doi, DoiError, RecordId};
18use crate::model::{ArtifactInfo, Record, RecordFile};
19use crate::pagination::Page;
20use crate::serde_util::deserialize_u64ish;
21
22/// Selector for a published record.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum RecordSelector {
25    /// Select by Zenodo record ID.
26    RecordId(
27        /// Record identifier.
28        RecordId,
29    ),
30    /// Select by DOI.
31    Doi(
32        /// DOI selector.
33        Doi,
34    ),
35}
36
37impl RecordSelector {
38    /// Selects a record by record ID.
39    #[must_use]
40    pub fn record_id(id: RecordId) -> Self {
41        Self::RecordId(id)
42    }
43
44    /// Selects a record by DOI string.
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use zenodo_rs::{RecordSelector, RecordId};
50    ///
51    /// assert_eq!(RecordSelector::record_id(RecordId(42)), RecordSelector::RecordId(RecordId(42)));
52    /// assert!(matches!(
53    ///     RecordSelector::doi("https://doi.org/10.5281/zenodo.42")?,
54    ///     RecordSelector::Doi(_)
55    /// ));
56    /// # Ok::<(), zenodo_rs::DoiError>(())
57    /// ```
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the DOI string is invalid.
62    pub fn doi(value: impl AsRef<str>) -> Result<Self, DoiError> {
63        Ok(Self::Doi(Doi::new(value)?))
64    }
65}
66
67impl From<RecordId> for RecordSelector {
68    fn from(value: RecordId) -> Self {
69        Self::RecordId(value)
70    }
71}
72
73impl From<Doi> for RecordSelector {
74    fn from(value: Doi) -> Self {
75        Self::Doi(value)
76    }
77}
78
79impl From<&Doi> for RecordSelector {
80    fn from(value: &Doi) -> Self {
81        Self::Doi(value.clone())
82    }
83}
84
85/// High-level selector for a downloadable artifact.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub enum ArtifactSelector {
88    /// Select a named file from a record.
89    FileByKey {
90        /// Record or DOI selector.
91        record: RecordSelector,
92        /// Exact file key.
93        key: String,
94        /// Whether to resolve the latest version first.
95        latest: bool,
96    },
97    /// Select the record archive.
98    Archive {
99        /// Record or DOI selector.
100        record: RecordSelector,
101        /// Whether to resolve the latest version first.
102        latest: bool,
103    },
104}
105
106impl ArtifactSelector {
107    /// Selects a named file from a specific record or DOI.
108    #[must_use]
109    pub fn file(record: impl Into<RecordSelector>, key: impl Into<String>) -> Self {
110        Self::FileByKey {
111            record: record.into(),
112            key: key.into(),
113            latest: false,
114        }
115    }
116
117    /// Selects a named file from the latest version of a record or DOI.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use zenodo_rs::{ArtifactSelector, RecordId, RecordSelector};
123    ///
124    /// assert_eq!(
125    ///     ArtifactSelector::latest_file(RecordId(42), "artifact.tar.gz"),
126    ///     ArtifactSelector::FileByKey {
127    ///         record: RecordSelector::RecordId(RecordId(42)),
128    ///         key: "artifact.tar.gz".into(),
129    ///         latest: true,
130    ///     }
131    /// );
132    /// assert!(matches!(
133    ///     ArtifactSelector::latest_file_by_doi("10.5281/zenodo.42", "artifact.tar.gz")?,
134    ///     ArtifactSelector::FileByKey { latest: true, .. }
135    /// ));
136    /// # Ok::<(), zenodo_rs::DoiError>(())
137    /// ```
138    #[must_use]
139    pub fn latest_file(record: impl Into<RecordSelector>, key: impl Into<String>) -> Self {
140        Self::FileByKey {
141            record: record.into(),
142            key: key.into(),
143            latest: true,
144        }
145    }
146
147    /// Selects a named file from a DOI string.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the DOI string is invalid.
152    pub fn file_by_doi(doi: impl AsRef<str>, key: impl Into<String>) -> Result<Self, DoiError> {
153        Ok(Self::file(RecordSelector::doi(doi)?, key))
154    }
155
156    /// Selects a named file from the latest version resolved from a DOI string.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the DOI string is invalid.
161    pub fn latest_file_by_doi(
162        doi: impl AsRef<str>,
163        key: impl Into<String>,
164    ) -> Result<Self, DoiError> {
165        Ok(Self::latest_file(RecordSelector::doi(doi)?, key))
166    }
167
168    /// Selects the archive for a specific record or DOI.
169    #[must_use]
170    pub fn archive(record: impl Into<RecordSelector>) -> Self {
171        Self::Archive {
172            record: record.into(),
173            latest: false,
174        }
175    }
176
177    /// Selects the archive for the latest version of a record or DOI.
178    #[must_use]
179    pub fn latest_archive(record: impl Into<RecordSelector>) -> Self {
180        Self::Archive {
181            record: record.into(),
182            latest: true,
183        }
184    }
185
186    /// Selects the archive for a DOI string.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the DOI string is invalid.
191    pub fn archive_by_doi(doi: impl AsRef<str>) -> Result<Self, DoiError> {
192        Ok(Self::archive(RecordSelector::doi(doi)?))
193    }
194
195    /// Selects the archive for the latest version resolved from a DOI string.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the DOI string is invalid.
200    pub fn latest_archive_by_doi(doi: impl AsRef<str>) -> Result<Self, DoiError> {
201        Ok(Self::latest_archive(RecordSelector::doi(doi)?))
202    }
203}
204
205/// Typed query parameters for the records search API.
206#[derive(Clone, Debug, PartialEq, Eq, Default)]
207pub struct RecordQuery {
208    /// Free-text query string.
209    pub q: Option<String>,
210    /// Record status filter.
211    pub status: Option<RecordQueryStatus>,
212    /// Sort order.
213    pub sort: Option<RecordSort>,
214    /// 1-based page number.
215    pub page: Option<u32>,
216    /// Page size.
217    pub size: Option<u32>,
218    /// Whether to include all versions in the search results.
219    pub all_versions: bool,
220    /// Community filters.
221    pub communities: Vec<String>,
222    /// Resource type filter.
223    pub resource_type: Option<String>,
224    /// Resource subtype filter.
225    pub subtype: Option<String>,
226    /// Extra raw query pairs for unsupported parameters.
227    pub custom: Vec<(String, String)>,
228}
229
230impl RecordQuery {
231    /// Starts building a typed record search query.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use zenodo_rs::RecordQuery;
237    ///
238    /// let query = RecordQuery::builder()
239    ///     .query("doi:\"10.5281/zenodo.42\"")
240    ///     .published()
241    ///     .most_recent()
242    ///     .size(10)
243    ///     .all_versions()
244    ///     .build();
245    ///
246    /// assert_eq!(query.q.as_deref(), Some("doi:\"10.5281/zenodo.42\""));
247    /// assert!(query.all_versions);
248    /// ```
249    #[must_use]
250    pub fn builder() -> RecordQueryBuilder {
251        RecordQueryBuilder::default()
252    }
253
254    /// Serializes the query into Zenodo URL parameter pairs.
255    ///
256    /// # Examples
257    ///
258    /// ```
259    /// use zenodo_rs::{RecordQuery, RecordQueryStatus, RecordSort};
260    ///
261    /// let pairs = RecordQuery {
262    ///     q: Some("doi:\"10.5281/zenodo.123\"".into()),
263    ///     status: Some(RecordQueryStatus::Published),
264    ///     sort: Some(RecordSort::MostRecent),
265    ///     page: Some(2),
266    ///     size: Some(25),
267    ///     all_versions: true,
268    ///     ..RecordQuery::default()
269    /// }
270    /// .into_pairs();
271    ///
272    /// assert!(pairs.contains(&("q".into(), "doi:\"10.5281/zenodo.123\"".into())));
273    /// assert!(pairs.contains(&("status".into(), "published".into())));
274    /// assert!(pairs.contains(&("sort".into(), "mostrecent".into())));
275    /// assert!(pairs.contains(&("all_versions".into(), "true".into())));
276    /// ```
277    #[must_use]
278    pub fn into_pairs(self) -> Vec<(String, String)> {
279        let mut pairs = Vec::new();
280
281        if let Some(q) = self.q {
282            pairs.push(("q".into(), q));
283        }
284        if let Some(status) = self.status {
285            pairs.push(("status".into(), status.to_string()));
286        }
287        if let Some(sort) = self.sort {
288            pairs.push(("sort".into(), sort.to_string()));
289        }
290        if let Some(page) = self.page {
291            pairs.push(("page".into(), page.to_string()));
292        }
293        if let Some(size) = self.size {
294            pairs.push(("size".into(), size.to_string()));
295        }
296        if self.all_versions {
297            pairs.push(("all_versions".into(), "true".into()));
298        }
299        if !self.communities.is_empty() {
300            pairs.push(("communities".into(), self.communities.join(",")));
301        }
302        if let Some(resource_type) = self.resource_type {
303            pairs.push(("type".into(), resource_type));
304        }
305        if let Some(subtype) = self.subtype {
306            pairs.push(("subtype".into(), subtype));
307        }
308        pairs.extend(self.custom);
309        pairs
310    }
311}
312
313/// Builder for [`RecordQuery`] values.
314#[derive(Clone, Debug, PartialEq, Eq, Default)]
315pub struct RecordQueryBuilder {
316    query: RecordQuery,
317}
318
319impl RecordQueryBuilder {
320    /// Sets the free-text query string.
321    #[must_use]
322    pub fn query(mut self, query: impl Into<String>) -> Self {
323        self.query.q = Some(query.into());
324        self
325    }
326
327    /// Sets the records API status filter.
328    #[must_use]
329    pub fn status(mut self, status: RecordQueryStatus) -> Self {
330        self.query.status = Some(status);
331        self
332    }
333
334    /// Filters to published records.
335    #[must_use]
336    pub fn published(mut self) -> Self {
337        self.query.status = Some(RecordQueryStatus::Published);
338        self
339    }
340
341    /// Filters to draft records.
342    #[must_use]
343    pub fn draft(mut self) -> Self {
344        self.query.status = Some(RecordQueryStatus::Draft);
345        self
346    }
347
348    /// Sets the records API sort order.
349    #[must_use]
350    pub fn sort(mut self, sort: RecordSort) -> Self {
351        self.query.sort = Some(sort);
352        self
353    }
354
355    /// Sorts by most recent first.
356    #[must_use]
357    pub fn most_recent(mut self) -> Self {
358        self.query.sort = Some(RecordSort::MostRecent);
359        self
360    }
361
362    /// Sets the 1-based page number.
363    #[must_use]
364    pub fn page(mut self, page: u32) -> Self {
365        self.query.page = Some(page);
366        self
367    }
368
369    /// Sets the page size.
370    #[must_use]
371    pub fn size(mut self, size: u32) -> Self {
372        self.query.size = Some(size);
373        self
374    }
375
376    /// Includes all versions in the search results.
377    #[must_use]
378    pub fn all_versions(mut self) -> Self {
379        self.query.all_versions = true;
380        self
381    }
382
383    /// Replaces the full community filter list.
384    #[must_use]
385    pub fn communities(mut self, communities: Vec<String>) -> Self {
386        self.query.communities = communities;
387        self
388    }
389
390    /// Adds one community filter.
391    #[must_use]
392    pub fn community(mut self, community: impl Into<String>) -> Self {
393        self.query.communities.push(community.into());
394        self
395    }
396
397    /// Sets the top-level resource type filter.
398    #[must_use]
399    pub fn resource_type(mut self, resource_type: impl Into<String>) -> Self {
400        self.query.resource_type = Some(resource_type.into());
401        self
402    }
403
404    /// Sets the resource subtype filter.
405    #[must_use]
406    pub fn subtype(mut self, subtype: impl Into<String>) -> Self {
407        self.query.subtype = Some(subtype.into());
408        self
409    }
410
411    /// Adds one unsupported raw query pair.
412    #[must_use]
413    pub fn custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
414        self.query.custom.push((key.into(), value.into()));
415        self
416    }
417
418    /// Builds the query value.
419    #[must_use]
420    pub fn build(self) -> RecordQuery {
421        self.query
422    }
423}
424
425/// Filter values for the records `status` query parameter.
426#[derive(Clone, Debug, PartialEq, Eq)]
427#[non_exhaustive]
428pub enum RecordQueryStatus {
429    /// Draft records.
430    Draft,
431    /// Published records.
432    Published,
433    /// Arbitrary server value not modeled directly by the crate.
434    Custom(
435        /// Raw server value.
436        String,
437    ),
438}
439
440impl std::fmt::Display for RecordQueryStatus {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        match self {
443            Self::Draft => write!(f, "draft"),
444            Self::Published => write!(f, "published"),
445            Self::Custom(value) => value.fmt(f),
446        }
447    }
448}
449
450/// Sort values for the records `sort` query parameter.
451#[derive(Clone, Debug, PartialEq, Eq)]
452#[non_exhaustive]
453pub enum RecordSort {
454    /// Relevance descending.
455    BestMatch,
456    /// Most recent first.
457    MostRecent,
458    /// Relevance ascending.
459    AscBestMatch,
460    /// Oldest first.
461    AscMostRecent,
462    /// Arbitrary server value not modeled directly by the crate.
463    Custom(
464        /// Raw server value.
465        String,
466    ),
467}
468
469impl std::fmt::Display for RecordSort {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        match self {
472            Self::BestMatch => write!(f, "bestmatch"),
473            Self::MostRecent => write!(f, "mostrecent"),
474            Self::AscBestMatch => write!(f, "-bestmatch"),
475            Self::AscMostRecent => write!(f, "-mostrecent"),
476            Self::Custom(value) => value.fmt(f),
477        }
478    }
479}
480
481#[derive(Deserialize)]
482#[serde(bound(deserialize = "T: Deserialize<'de>"))]
483struct SearchEnvelope<T> {
484    hits: SearchHits<T>,
485    #[serde(default)]
486    links: SearchLinks,
487}
488
489#[derive(Deserialize)]
490#[serde(bound(deserialize = "T: Deserialize<'de>"))]
491struct SearchHits<T> {
492    #[serde(default)]
493    hits: Vec<T>,
494    #[serde(default)]
495    total: Option<SearchTotal>,
496}
497
498#[derive(Deserialize)]
499#[serde(untagged)]
500enum SearchTotal {
501    Number(#[serde(deserialize_with = "deserialize_u64ish")] u64),
502    Object {
503        #[serde(deserialize_with = "deserialize_u64ish")]
504        value: u64,
505    },
506}
507
508impl SearchTotal {
509    fn into_u64(self) -> u64 {
510        match self {
511            Self::Number(value) | Self::Object { value } => value,
512        }
513    }
514}
515
516#[derive(Default, Deserialize)]
517struct SearchLinks {
518    #[serde(default)]
519    next: Option<Url>,
520    #[serde(default)]
521    prev: Option<Url>,
522}
523
524impl<T> From<SearchEnvelope<T>> for Page<T> {
525    fn from(value: SearchEnvelope<T>) -> Self {
526        Self {
527            hits: value.hits.hits,
528            total: value.hits.total.map(SearchTotal::into_u64),
529            next: value.links.next,
530            prev: value.links.prev,
531        }
532    }
533}
534
535impl ZenodoClient {
536    /// Searches published records using Zenodo's records API.
537    ///
538    /// # Examples
539    ///
540    /// ```no_run
541    /// use zenodo_rs::{Auth, RecordQuery, ZenodoClient};
542    ///
543    /// #[tokio::main]
544    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
545    ///     let client = ZenodoClient::new(Auth::new("token"))?;
546    ///     let page = client
547    ///         .search_records(
548    ///             &RecordQuery::builder()
549    ///                 .query("doi:\"10.5281/zenodo.123\"")
550    ///                 .published()
551    ///                 .most_recent()
552    ///                 .size(10)
553    ///                 .build(),
554    ///         )
555    ///         .await?;
556    ///     let _ = page.hits;
557    ///     Ok(())
558    /// }
559    /// ```
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if the request fails or Zenodo returns malformed
564    /// search data.
565    pub async fn search_records(&self, query: &RecordQuery) -> Result<Page<Record>, ZenodoError> {
566        let pairs = query.clone().into_pairs();
567        self.execute_json::<SearchEnvelope<Record>>(
568            self.request(reqwest::Method::GET, "records")?.query(&pairs),
569        )
570        .await
571        .map(Into::into)
572    }
573
574    /// Fetches a published record by record ID.
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if the request fails or Zenodo returns a non-success
579    /// response.
580    pub async fn get_record(&self, id: RecordId) -> Result<Record, ZenodoError> {
581        self.execute_json(self.request(reqwest::Method::GET, &format!("records/{id}"))?)
582            .await
583    }
584
585    /// Resolves a DOI to a published record.
586    ///
587    /// # Examples
588    ///
589    /// ```no_run
590    /// use zenodo_rs::{Auth, ZenodoClient};
591    ///
592    /// #[tokio::main]
593    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
594    ///     let client = ZenodoClient::new(Auth::new("token"))?;
595    ///     let record = client
596    ///         .get_record_by_doi_str("https://doi.org/10.5281/zenodo.123")
597    ///         .await?;
598    ///     let _ = record.id;
599    ///     Ok(())
600    /// }
601    /// ```
602    ///
603    /// # Errors
604    ///
605    /// Returns an error if the search fails or no record matches the DOI.
606    pub async fn get_record_by_doi(&self, doi: &Doi) -> Result<Record, ZenodoError> {
607        let mut page = self
608            .search_records(
609                &RecordQuery::builder()
610                    .query(format!("doi:\"{doi}\" OR conceptdoi:\"{doi}\""))
611                    .size(25)
612                    .all_versions()
613                    .build(),
614            )
615            .await?;
616
617        loop {
618            if let Some(record) = page
619                .hits
620                .into_iter()
621                .find(|record| record_matches_doi(record, doi))
622            {
623                return Ok(record);
624            }
625
626            let Some(next) = page.next else {
627                break;
628            };
629            page = self
630                .execute_json::<SearchEnvelope<Record>>(
631                    self.request_url(reqwest::Method::GET, next)?,
632                )
633                .await?
634                .into();
635        }
636
637        Err(ZenodoError::UnsupportedSelector(format!(
638            "no exact record found for DOI {doi}"
639        )))
640    }
641
642    /// Parses a DOI string and resolves it to a published record.
643    ///
644    /// # Errors
645    ///
646    /// Returns an error if the DOI string is invalid, if the search fails, or
647    /// if no record matches the DOI.
648    pub async fn get_record_by_doi_str(&self, doi: impl AsRef<str>) -> Result<Record, ZenodoError> {
649        let doi = Doi::new(doi).map_err(|error| {
650            ZenodoError::UnsupportedSelector(format!("invalid DOI selector: {error}"))
651        })?;
652        self.get_record_by_doi(&doi).await
653    }
654
655    /// Resolves a DOI and then follows the latest-version link when present.
656    ///
657    /// # Errors
658    ///
659    /// Returns an error if DOI resolution fails or the latest record cannot be
660    /// fetched.
661    pub async fn resolve_latest_by_doi(&self, doi: &Doi) -> Result<Record, ZenodoError> {
662        let record = self.get_record_by_doi(doi).await?;
663        self.resolve_latest_from_record(record).await
664    }
665
666    /// Parses a DOI string and resolves the latest version in that record family.
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if the DOI string is invalid, if DOI resolution fails,
671    /// or if the latest record cannot be fetched.
672    pub async fn resolve_latest_by_doi_str(
673        &self,
674        doi: impl AsRef<str>,
675    ) -> Result<Record, ZenodoError> {
676        let doi = Doi::new(doi).map_err(|error| {
677            ZenodoError::UnsupportedSelector(format!("invalid DOI selector: {error}"))
678        })?;
679        self.resolve_latest_by_doi(&doi).await
680    }
681
682    /// Fetches the latest record version for a record family.
683    ///
684    /// # Errors
685    ///
686    /// Returns an error if record lookup fails or the latest record cannot be
687    /// fetched.
688    pub async fn get_latest_record(&self, id: RecordId) -> Result<Record, ZenodoError> {
689        self.resolve_latest_version(id).await
690    }
691
692    /// Resolves the latest record version starting from a record ID.
693    ///
694    /// # Errors
695    ///
696    /// Returns an error if record lookup fails or the latest record cannot be
697    /// fetched.
698    pub async fn resolve_latest_version(&self, id: RecordId) -> Result<Record, ZenodoError> {
699        let record = self.get_record(id).await?;
700        self.resolve_latest_from_record(record).await
701    }
702
703    /// Lists the versions associated with a record family.
704    ///
705    /// # Errors
706    ///
707    /// Returns an error if the record lookup fails or the versions query cannot
708    /// be completed.
709    pub async fn list_record_versions(&self, id: RecordId) -> Result<Page<Record>, ZenodoError> {
710        let record = self.get_record(id).await?;
711        if let Some(versions_url) = record.links.versions.clone() {
712            return self
713                .execute_json::<SearchEnvelope<Record>>(
714                    self.request_url(reqwest::Method::GET, versions_url)?,
715                )
716                .await
717                .map(Into::into);
718        }
719
720        if let Some(conceptrecid) = record.conceptrecid {
721            return self
722                .search_records(
723                    &RecordQuery::builder()
724                        .query(format!("conceptrecid:{}", conceptrecid.0))
725                        .all_versions()
726                        .most_recent()
727                        .build(),
728                )
729                .await;
730        }
731
732        Ok(Page {
733            hits: vec![record],
734            total: Some(1),
735            next: None,
736            prev: None,
737        })
738    }
739
740    /// Lists files attached to a specific record.
741    ///
742    /// # Errors
743    ///
744    /// Returns an error if the record lookup fails.
745    pub async fn list_record_files(&self, id: RecordId) -> Result<Vec<RecordFile>, ZenodoError> {
746        Ok(self.get_record(id).await?.files)
747    }
748
749    /// Returns a record together with its latest version and keyed files map.
750    ///
751    /// # Examples
752    ///
753    /// ```no_run
754    /// use zenodo_rs::{Auth, RecordId, ZenodoClient};
755    ///
756    /// #[tokio::main]
757    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
758    ///     let client = ZenodoClient::new(Auth::new("token"))?;
759    ///     let info = client.get_artifact_info(RecordId(123)).await?;
760    ///     let _ = info.files_by_key;
761    ///     Ok(())
762    /// }
763    /// ```
764    ///
765    /// # Errors
766    ///
767    /// Returns an error if record lookup or latest-version resolution fails.
768    pub async fn get_artifact_info(&self, id: RecordId) -> Result<ArtifactInfo, ZenodoError> {
769        let record = self.get_record(id).await?;
770        let latest = self.resolve_latest_from_record(record.clone()).await?;
771        let files_by_key = latest
772            .files
773            .iter()
774            .cloned()
775            .map(|file| (file.key.clone(), file))
776            .collect();
777
778        Ok(ArtifactInfo {
779            record,
780            latest,
781            files_by_key,
782        })
783    }
784
785    /// Resolves artifact information starting from a DOI.
786    ///
787    /// # Errors
788    ///
789    /// Returns an error if DOI resolution fails or latest-version resolution
790    /// fails.
791    pub async fn get_artifact_info_by_doi(&self, doi: &Doi) -> Result<ArtifactInfo, ZenodoError> {
792        let record = self.get_record_by_doi(doi).await?;
793        self.get_artifact_info(record.id).await
794    }
795
796    pub(crate) async fn resolve_record_selector(
797        &self,
798        selector: &RecordSelector,
799    ) -> Result<Record, ZenodoError> {
800        match selector {
801            RecordSelector::RecordId(id) => self.get_record(*id).await,
802            RecordSelector::Doi(doi) => self.get_record_by_doi(doi).await,
803        }
804    }
805
806    pub(crate) async fn resolve_latest_from_record(
807        &self,
808        record: Record,
809    ) -> Result<Record, ZenodoError> {
810        match record.latest_url() {
811            Some(latest_url) => self.get_record_by_url(latest_url).await,
812            None => Ok(record),
813        }
814    }
815}
816
817fn record_matches_doi(record: &Record, doi: &Doi) -> bool {
818    record.doi.as_ref() == Some(doi) || record.conceptdoi.as_ref() == Some(doi)
819}
820
821#[cfg(test)]
822mod tests {
823    use super::{
824        record_matches_doi, ArtifactSelector, RecordQuery, RecordQueryStatus, RecordSelector,
825        RecordSort, SearchEnvelope,
826    };
827    use crate::client::{Auth, ZenodoClient};
828    use crate::{Doi, Endpoint, Record, RecordId, ZenodoError};
829    use url::Url;
830
831    #[test]
832    fn query_serialization_uses_zenodo_parameter_names() {
833        let pairs = RecordQuery {
834            q: Some("title:test".into()),
835            page: Some(2),
836            size: Some(50),
837            all_versions: true,
838            communities: vec!["alpha".into(), "beta".into()],
839            resource_type: Some("dataset".into()),
840            subtype: Some("image".into()),
841            custom: vec![("foo".into(), "bar".into())],
842            ..RecordQuery::default()
843        }
844        .into_pairs();
845
846        assert!(pairs.contains(&("q".into(), "title:test".into())));
847        assert!(pairs.contains(&("page".into(), "2".into())));
848        assert!(pairs.contains(&("size".into(), "50".into())));
849        assert!(pairs.contains(&("all_versions".into(), "true".into())));
850        assert!(pairs.contains(&("communities".into(), "alpha,beta".into())));
851        assert!(pairs.contains(&("type".into(), "dataset".into())));
852        assert!(pairs.contains(&("subtype".into(), "image".into())));
853        assert!(pairs.contains(&("foo".into(), "bar".into())));
854    }
855
856    #[test]
857    fn query_builder_covers_common_search_configuration() {
858        let pairs = RecordQuery::builder()
859            .query("doi:\"10.5281/zenodo.1\"")
860            .published()
861            .most_recent()
862            .page(2)
863            .size(25)
864            .all_versions()
865            .community("zenodo")
866            .resource_type("dataset")
867            .subtype("image")
868            .custom("foo", "bar")
869            .build()
870            .into_pairs();
871
872        assert!(pairs.contains(&("q".into(), "doi:\"10.5281/zenodo.1\"".into())));
873        assert!(pairs.contains(&("status".into(), "published".into())));
874        assert!(pairs.contains(&("sort".into(), "mostrecent".into())));
875        assert!(pairs.contains(&("page".into(), "2".into())));
876        assert!(pairs.contains(&("size".into(), "25".into())));
877        assert!(pairs.contains(&("all_versions".into(), "true".into())));
878        assert!(pairs.contains(&("communities".into(), "zenodo".into())));
879        assert!(pairs.contains(&("type".into(), "dataset".into())));
880        assert!(pairs.contains(&("subtype".into(), "image".into())));
881        assert!(pairs.contains(&("foo".into(), "bar".into())));
882    }
883
884    #[test]
885    fn selector_and_display_helpers_cover_custom_variants() {
886        let doi = Doi::new("10.5281/zenodo.1").unwrap();
887        assert!(matches!(
888            RecordSelector::from(RecordId(1)),
889            RecordSelector::RecordId(_)
890        ));
891        assert!(matches!(
892            RecordSelector::from(doi.clone()),
893            RecordSelector::Doi(_)
894        ));
895        assert!(matches!(RecordSelector::from(&doi), RecordSelector::Doi(_)));
896        assert_eq!(RecordSort::BestMatch.to_string(), "bestmatch");
897        assert_eq!(RecordQueryStatus::Draft.to_string(), "draft");
898        assert_eq!(RecordQueryStatus::Custom("mine".into()).to_string(), "mine");
899        assert_eq!(RecordSort::AscBestMatch.to_string(), "-bestmatch");
900        assert_eq!(RecordSort::AscMostRecent.to_string(), "-mostrecent");
901        assert_eq!(RecordSort::Custom("rank".into()).to_string(), "rank");
902        assert!(matches!(
903            RecordSelector::record_id(RecordId(1)),
904            RecordSelector::RecordId(_)
905        ));
906        assert!(matches!(
907            RecordSelector::doi("10.5281/zenodo.1").unwrap(),
908            RecordSelector::Doi(_)
909        ));
910        assert_eq!(
911            ArtifactSelector::file(RecordId(1), "artifact.bin"),
912            ArtifactSelector::FileByKey {
913                record: RecordSelector::RecordId(RecordId(1)),
914                key: "artifact.bin".into(),
915                latest: false,
916            }
917        );
918        assert_eq!(
919            ArtifactSelector::latest_archive_by_doi("10.5281/zenodo.1").unwrap(),
920            ArtifactSelector::Archive {
921                record: RecordSelector::Doi(Doi::new("10.5281/zenodo.1").unwrap()),
922                latest: true,
923            }
924        );
925        assert_eq!(
926            ArtifactSelector::latest_file_by_doi("10.5281/zenodo.1", "artifact.bin").unwrap(),
927            ArtifactSelector::FileByKey {
928                record: RecordSelector::Doi(Doi::new("10.5281/zenodo.1").unwrap()),
929                key: "artifact.bin".into(),
930                latest: true,
931            }
932        );
933        assert_eq!(
934            ArtifactSelector::file_by_doi("10.5281/zenodo.1", "artifact.bin").unwrap(),
935            ArtifactSelector::FileByKey {
936                record: RecordSelector::Doi(Doi::new("10.5281/zenodo.1").unwrap()),
937                key: "artifact.bin".into(),
938                latest: false,
939            }
940        );
941        assert_eq!(
942            ArtifactSelector::archive(RecordId(9)),
943            ArtifactSelector::Archive {
944                record: RecordSelector::RecordId(RecordId(9)),
945                latest: false,
946            }
947        );
948        assert_eq!(
949            ArtifactSelector::latest_archive(RecordId(9)),
950            ArtifactSelector::Archive {
951                record: RecordSelector::RecordId(RecordId(9)),
952                latest: true,
953            }
954        );
955        assert_eq!(
956            ArtifactSelector::archive_by_doi("10.5281/zenodo.1").unwrap(),
957            ArtifactSelector::Archive {
958                record: RecordSelector::Doi(Doi::new("10.5281/zenodo.1").unwrap()),
959                latest: false,
960            }
961        );
962    }
963
964    #[test]
965    fn query_builder_exercises_remaining_methods() {
966        let query = RecordQuery::builder()
967            .query("title:test")
968            .status(RecordQueryStatus::Custom("custom".into()))
969            .sort(RecordSort::AscMostRecent)
970            .draft()
971            .page(3)
972            .size(15)
973            .communities(vec!["alpha".into(), "beta".into()])
974            .community("gamma")
975            .resource_type("software")
976            .subtype("source-code")
977            .build();
978
979        assert_eq!(query.q.as_deref(), Some("title:test"));
980        assert_eq!(query.status, Some(RecordQueryStatus::Draft));
981        assert_eq!(query.sort, Some(RecordSort::AscMostRecent));
982        assert_eq!(query.page, Some(3));
983        assert_eq!(query.size, Some(15));
984        assert_eq!(query.communities, vec!["alpha", "beta", "gamma"]);
985        assert_eq!(query.resource_type.as_deref(), Some("software"));
986        assert_eq!(query.subtype.as_deref(), Some("source-code"));
987    }
988
989    #[test]
990    fn doi_matching_accepts_record_and_concept_doi_only() {
991        let doi = Doi::new("https://doi.org/10.5281/ZENODO.1").unwrap();
992        let record: Record = serde_json::from_value(serde_json::json!({
993            "id": 1,
994            "recid": 1,
995            "doi": "10.5281/zenodo.1",
996            "conceptdoi": "10.5281/zenodo.2",
997            "metadata": { "title": "artifact" },
998            "files": [],
999            "links": {}
1000        }))
1001        .unwrap();
1002        let concept_only: Record = serde_json::from_value(serde_json::json!({
1003            "id": 2,
1004            "recid": 2,
1005            "conceptdoi": "10.5281/zenodo.1",
1006            "metadata": { "title": "artifact" },
1007            "files": [],
1008            "links": {}
1009        }))
1010        .unwrap();
1011        let mismatch: Record = serde_json::from_value(serde_json::json!({
1012            "id": 3,
1013            "recid": 3,
1014            "doi": "10.5281/zenodo.999",
1015            "metadata": { "title": "artifact" },
1016            "files": [],
1017            "links": {}
1018        }))
1019        .unwrap();
1020
1021        assert!(record_matches_doi(&record, &doi));
1022        assert!(record_matches_doi(&concept_only, &doi));
1023        assert!(!record_matches_doi(&mismatch, &doi));
1024    }
1025
1026    #[test]
1027    fn search_totals_accept_integer_like_numeric_shapes() {
1028        let from_number: SearchEnvelope<Record> = serde_json::from_value(serde_json::json!({
1029            "hits": {
1030                "hits": [],
1031                "total": 14.0
1032            }
1033        }))
1034        .unwrap();
1035        let from_object: SearchEnvelope<Record> = serde_json::from_value(serde_json::json!({
1036            "hits": {
1037                "hits": [],
1038                "total": {
1039                    "value": "15"
1040                }
1041            }
1042        }))
1043        .unwrap();
1044
1045        assert_eq!(super::Page::from(from_number).total, Some(14));
1046        assert_eq!(super::Page::from(from_object).total, Some(15));
1047    }
1048
1049    #[tokio::test]
1050    async fn doi_string_helpers_reject_invalid_selectors_before_requesting() {
1051        let client = ZenodoClient::builder(Auth::new("token"))
1052            .endpoint(Endpoint::Custom(
1053                Url::parse("http://localhost:9/api/").unwrap(),
1054            ))
1055            .build()
1056            .unwrap();
1057
1058        let get_error = client
1059            .get_record_by_doi_str("definitely not a doi")
1060            .await
1061            .unwrap_err();
1062        let latest_error = client
1063            .resolve_latest_by_doi_str("still not a doi")
1064            .await
1065            .unwrap_err();
1066
1067        assert!(matches!(
1068            get_error,
1069            ZenodoError::UnsupportedSelector(message)
1070            if message.starts_with("invalid DOI selector:")
1071        ));
1072        assert!(matches!(
1073            latest_error,
1074            ZenodoError::UnsupportedSelector(message)
1075            if message.starts_with("invalid DOI selector:")
1076        ));
1077    }
1078}