1use 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#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum RecordSelector {
25 RecordId(
27 RecordId,
29 ),
30 Doi(
32 Doi,
34 ),
35}
36
37impl RecordSelector {
38 #[must_use]
40 pub fn record_id(id: RecordId) -> Self {
41 Self::RecordId(id)
42 }
43
44 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#[derive(Clone, Debug, PartialEq, Eq)]
87pub enum ArtifactSelector {
88 FileByKey {
90 record: RecordSelector,
92 key: String,
94 latest: bool,
96 },
97 Archive {
99 record: RecordSelector,
101 latest: bool,
103 },
104}
105
106impl ArtifactSelector {
107 #[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 #[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 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 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 #[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 #[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 pub fn archive_by_doi(doi: impl AsRef<str>) -> Result<Self, DoiError> {
192 Ok(Self::archive(RecordSelector::doi(doi)?))
193 }
194
195 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#[derive(Clone, Debug, PartialEq, Eq, Default)]
207pub struct RecordQuery {
208 pub q: Option<String>,
210 pub status: Option<RecordQueryStatus>,
212 pub sort: Option<RecordSort>,
214 pub page: Option<u32>,
216 pub size: Option<u32>,
218 pub all_versions: bool,
220 pub communities: Vec<String>,
222 pub resource_type: Option<String>,
224 pub subtype: Option<String>,
226 pub custom: Vec<(String, String)>,
228}
229
230impl RecordQuery {
231 #[must_use]
250 pub fn builder() -> RecordQueryBuilder {
251 RecordQueryBuilder::default()
252 }
253
254 #[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#[derive(Clone, Debug, PartialEq, Eq, Default)]
315pub struct RecordQueryBuilder {
316 query: RecordQuery,
317}
318
319impl RecordQueryBuilder {
320 #[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 #[must_use]
329 pub fn status(mut self, status: RecordQueryStatus) -> Self {
330 self.query.status = Some(status);
331 self
332 }
333
334 #[must_use]
336 pub fn published(mut self) -> Self {
337 self.query.status = Some(RecordQueryStatus::Published);
338 self
339 }
340
341 #[must_use]
343 pub fn draft(mut self) -> Self {
344 self.query.status = Some(RecordQueryStatus::Draft);
345 self
346 }
347
348 #[must_use]
350 pub fn sort(mut self, sort: RecordSort) -> Self {
351 self.query.sort = Some(sort);
352 self
353 }
354
355 #[must_use]
357 pub fn most_recent(mut self) -> Self {
358 self.query.sort = Some(RecordSort::MostRecent);
359 self
360 }
361
362 #[must_use]
364 pub fn page(mut self, page: u32) -> Self {
365 self.query.page = Some(page);
366 self
367 }
368
369 #[must_use]
371 pub fn size(mut self, size: u32) -> Self {
372 self.query.size = Some(size);
373 self
374 }
375
376 #[must_use]
378 pub fn all_versions(mut self) -> Self {
379 self.query.all_versions = true;
380 self
381 }
382
383 #[must_use]
385 pub fn communities(mut self, communities: Vec<String>) -> Self {
386 self.query.communities = communities;
387 self
388 }
389
390 #[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 #[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 #[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 #[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 #[must_use]
420 pub fn build(self) -> RecordQuery {
421 self.query
422 }
423}
424
425#[derive(Clone, Debug, PartialEq, Eq)]
427#[non_exhaustive]
428pub enum RecordQueryStatus {
429 Draft,
431 Published,
433 Custom(
435 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#[derive(Clone, Debug, PartialEq, Eq)]
452#[non_exhaustive]
453pub enum RecordSort {
454 BestMatch,
456 MostRecent,
458 AscBestMatch,
460 AscMostRecent,
462 Custom(
464 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 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 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 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 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 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 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 pub async fn get_latest_record(&self, id: RecordId) -> Result<Record, ZenodoError> {
689 self.resolve_latest_version(id).await
690 }
691
692 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 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 pub async fn list_record_files(&self, id: RecordId) -> Result<Vec<RecordFile>, ZenodoError> {
746 Ok(self.get_record(id).await?.files)
747 }
748
749 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 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}