1use crate::prelude::*;
12use crate::schema::namespaces::{
13 ARXIV_DATACITE_REGISTRANT_CODE, DATACITE_DOI_DIRECTORY_INDICATOR, DEFAULT_ARXIV_SCHEMA_URI, DEFAULT_DOI_SCHEMA_URI, DEFAULT_ORCID_SCHEMA_URI,
14 DEFAULT_ROR_SCHEMA_URI,
15};
16use crate::util::constants::{
17 HTTP_URL, RE_ARK, RE_ARK_TEXT, RE_ARXIV, RE_ARXIV_TEXT, RE_DOI, RE_DOI_TEXT, RE_ISBN, RE_ISBN_TEXT, RE_ORCID, RE_ORCID_TEXT, RE_RAID_TEXT,
18 RE_ROR, RE_ROR_TEXT,
19};
20use crate::util::{base32_crockford_decode, regex_capture_lookup, trim_unmatched_trailing_parentheses, ToStringChunks};
21use bon::Builder;
22use core::fmt;
23#[cfg(feature = "std")]
24use data_encoding::HEXLOWER;
25#[cfg(feature = "std")]
26use ring::digest::{digest, SHA256};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use strum::{EnumIs, EnumIter, IntoEnumIterator};
30use validator::ValidationError;
31
32pub mod patent;
33pub mod raid;
34
35pub use patent::Patent;
36
37const BETANUMERIC_DIGITS: &str = "0123456789bcdfghjkmnpqrstvwxz";
38
39pub trait Betanumeric {
43 fn is_betanumeric(&self) -> bool {
45 false
46 }
47 fn to_betanumeric_ordinal(&self) -> Option<usize>;
51}
52pub trait PersistentIdentifier: fmt::Display {
54 fn new() -> Self;
56 fn schema_uri(&self) -> String;
61 fn identifier(&self) -> String;
67 fn prefix(&self) -> Option<String> {
71 None
72 }
73 fn suffix(&self) -> Option<String>;
77 fn check_digit(&self) -> Option<Vec<char>> {
79 None
80 }
81 fn url(&self) -> String {
83 String::new()
84 }
85}
86pub trait PersistentIdentifierConvert<T: AsRef<str>> {
88 fn format_as(&self, pid_type: PID) -> String;
96 fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal;
104 fn is_pid(&self, _pid_type: PID) -> bool;
111 fn is_ark(&self) -> bool;
118 fn is_arxiv(&self) -> bool;
120 fn is_doi(&self) -> bool;
127 fn is_isbn(&self) -> bool {
129 false
130 }
131 fn is_orcid(&self) -> bool;
138 fn is_raid(&self) -> bool;
145 fn is_ror(&self) -> bool;
152}
153pub trait PersistentIdentifierParse {
155 fn find_all(value: impl ToString) -> Vec<Self>
157 where
158 Self: Sized;
159 fn format(value: impl ToString) -> String;
161 fn from_string(value: impl ToString) -> Self
163 where
164 Self: Sized;
165 fn is_valid(value: impl ToString) -> bool;
167}
168#[derive(Clone, Debug, Default, EnumIs, EnumIter, Eq, Ord, PartialEq, PartialOrd)]
172pub enum PID {
173 #[default]
175 Unknown,
176 ARK,
182 ARXIV,
186 DOI,
190 ISBN,
194 ORCID,
198 Patent,
200 PIDINST,
206 RAID,
214 ROR,
220 URL,
222}
223#[derive(Default)]
225pub struct PersistentIdentifierInternal {
226 value: String,
228 pid_type: PID,
230}
231#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
233#[builder(start_fn = init, on(String, into))]
234pub struct Identifier {
235 pub kind: PID,
237 pub value: String,
239}
240#[derive(Clone, Debug)]
242pub enum PublicationIdentifierType {
243 Doi(DOI),
245 Arxiv(ARXIV),
247 Unknown,
249}
250impl From<&str> for PublicationIdentifierType {
251 fn from(value: &str) -> Self {
252 match (DOI::is_valid(value), ARXIV::is_valid(value)) {
253 | (true, _) => Self::Doi(DOI::from_string(value)),
254 | (_, true) => Self::Arxiv(ARXIV::from_string(value)),
255 | _ => Self::Unknown,
256 }
257 }
258}
259#[derive(Builder, Clone, Debug)]
271#[builder(start_fn = init, on(String, into))]
272pub struct ARK {
273 pub assigned_name: Option<String>,
281 #[builder(default = "ark:".to_string())]
285 pub label: String,
286 pub name_assigning_authority_number: Option<String>,
295 pub name_mapping_authority: Option<String>,
300 #[builder(default = Vec::new())]
304 pub parts: Vec<String>,
305 #[builder(default = Vec::new())]
309 pub variants: Vec<String>,
310}
311#[derive(Builder, Clone, Debug)]
319#[builder(start_fn = init, on(String, into))]
320pub struct DOI {
321 pub schema_uri: Option<String>,
323 pub directory_indicator: Option<String>,
328 pub registrant_code: Option<String>,
333 pub suffix: Option<String>,
340}
341#[derive(Builder, Clone, Debug)]
345#[builder(start_fn = init, on(String, into))]
346pub struct ARXIV {
347 pub schema_uri: Option<String>,
349 pub archive: Option<String>,
351 pub identifier: Option<String>,
353 pub version: Option<String>,
355}
356#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
364#[builder(start_fn = init, on(String, into))]
365pub struct ISBN {
366 pub prefix_element: Option<String>,
370 pub registration_group: Option<String>,
374 pub publisher: Option<String>,
376 pub title: Option<String>,
378 pub check_digit: Option<String>,
382}
383#[derive(Builder, Clone, Debug)]
391#[builder(start_fn = init, on(String, into))]
392pub struct ORCID {
393 pub schema_uri: Option<String>,
395 pub identifier: Option<String>,
398 pub check_digit: Option<String>,
402}
403#[derive(Builder, Clone, Debug)]
411#[builder(start_fn = init, on(String, into))]
412pub struct RAID {
413 pub schema_uri: Option<String>,
415 pub prefix: Option<String>,
417 pub suffix: Option<String>,
419 pub metadata: Option<raid::Metadata>,
423}
424#[derive(Builder, Clone, Debug)]
430#[builder(start_fn = init, on(String, into))]
431pub struct ROR {
432 pub schema_uri: Option<String>,
434 pub identifier: Option<String>,
436 pub check_digit: Option<String>,
440}
441impl Identifier {
442 pub fn new(value: impl Into<String>) -> Self {
444 Self {
445 kind: PID::Unknown,
446 value: value.into(),
447 }
448 }
449 pub fn normalized(&self) -> Option<Self> {
451 let trimmed = self
452 .value
453 .trim()
454 .trim_matches(|character: char| matches!(character, '<' | '>' | '(' | ')' | '[' | ']' | ',' | ';'));
455 let trimmed = trim_unmatched_trailing_parentheses(trimmed);
456 match self.kind {
457 | PID::DOI => Self::parsed::<DOI>(PID::DOI, trimmed),
458 | PID::ARXIV => Self::parsed::<ARXIV>(PID::ARXIV, trimmed),
459 | PID::ISBN => Self::parsed::<ISBN>(PID::ISBN, trimmed),
460 | PID::ORCID => Self::parsed::<ORCID>(PID::ORCID, trimmed),
461 | PID::ARK => Self::parsed::<ARK>(PID::ARK, trimmed),
462 | PID::RAID => Self::parsed::<RAID>(PID::RAID, trimmed),
463 | PID::Patent => Self::parsed::<Patent>(PID::Patent, trimmed),
464 | PID::ROR => Self::parsed::<ROR>(PID::ROR, trimmed),
465 | PID::URL if HTTP_URL.is_match(trimmed).unwrap_or(false) => Some(Self {
466 kind: PID::URL,
467 value: trimmed.trim_end_matches('/').to_string(),
468 }),
469 | PID::Unknown => {
470 let lowercase = trimmed.to_ascii_lowercase();
471 let primary = if lowercase.starts_with("raid:") || lowercase.starts_with("https://raid.org/") {
472 PID::RAID
473 } else {
474 PID::DOI
475 };
476 [PID::ARXIV, primary, PID::ARK, PID::ISBN, PID::ORCID, PID::Patent, PID::ROR, PID::URL]
477 .into_iter()
478 .find_map(|kind| {
479 Self {
480 kind,
481 value: self.value.clone(),
482 }
483 .normalized()
484 })
485 }
486 | _ => None,
487 }
488 }
489 pub fn normalize(value: &str) -> String {
491 let value = value.trim();
492 match value.split_once(':') {
493 | Some((prefix, identifier)) if matches!(prefix.to_ascii_lowercase().as_str(), "arxiv" | "doi" | "raid" | "isbn" | "patent") => {
494 format!("{}:{}", prefix.to_ascii_lowercase(), identifier.trim().to_ascii_lowercase())
495 }
496 | Some((prefix, identifier)) => format!("{}:{}", prefix.to_ascii_lowercase(), identifier.trim()),
497 | None => value.to_string(),
498 }
499 }
500 pub fn identity_key(&self) -> String {
502 match self.kind {
503 | PID::ARXIV => {
504 let identifier = ARXIV::from_string(&self.value).work_identifier();
505 Self::normalize(&format!("arxiv:{}", identifier.trim_start_matches("arXiv:")))
506 }
507 | _ => Self::normalize(&format!("{}:{}", self.kind.as_str(), self.value)),
508 }
509 }
510 fn parsed<T: PersistentIdentifierParse + fmt::Display>(kind: PID, value: &str) -> Option<Self> {
511 T::find_all(value)
512 .first()
513 .map(T::format)
514 .filter(|value| T::is_valid(value))
515 .map(|value| Self { kind, value })
516 }
517 #[cfg(feature = "std")]
519 pub fn identifier_hash(&self) -> String {
520 HEXLOWER.encode(digest(&SHA256, self.value.as_bytes()).as_ref())[..12].to_string()
521 }
522}
523impl<'a> From<&'a Identifier> for &'a str {
524 fn from(identifier: &'a Identifier) -> Self {
525 identifier.kind.as_str()
526 }
527}
528impl From<&str> for Identifier {
529 fn from(value: &str) -> Self {
530 Self::new(value)
531 }
532}
533impl From<ARK> for Identifier {
534 fn from(value: ARK) -> Self {
535 Self {
536 kind: PID::ARK,
537 value: value.to_string(),
538 }
539 }
540}
541impl From<ARXIV> for Identifier {
542 fn from(value: ARXIV) -> Self {
543 Self {
544 kind: PID::ARXIV,
545 value: value.to_string(),
546 }
547 }
548}
549impl From<DOI> for Identifier {
550 fn from(value: DOI) -> Self {
551 Self {
552 kind: PID::DOI,
553 value: value.to_string(),
554 }
555 }
556}
557impl From<ISBN> for Identifier {
558 fn from(value: ISBN) -> Self {
559 Self {
560 kind: PID::ISBN,
561 value: value.to_string(),
562 }
563 }
564}
565impl From<ORCID> for Identifier {
566 fn from(value: ORCID) -> Self {
567 Self {
568 kind: PID::ORCID,
569 value: value.to_string(),
570 }
571 }
572}
573impl From<Patent> for Identifier {
574 fn from(value: Patent) -> Self {
575 Self {
576 kind: PID::Patent,
577 value: value.to_string(),
578 }
579 }
580}
581impl From<RAID> for Identifier {
582 fn from(value: RAID) -> Self {
583 Self {
584 kind: PID::RAID,
585 value: value.to_string(),
586 }
587 }
588}
589impl From<ROR> for Identifier {
590 fn from(value: ROR) -> Self {
591 Self {
592 kind: PID::ROR,
593 value: value.to_string(),
594 }
595 }
596}
597impl PID {
598 pub fn is_discoverable(&self) -> bool {
600 self.is_ark()
601 || self.is_arxiv()
602 || self.is_doi()
603 || self.is_isbn()
604 || self.is_orcid()
605 || self.is_patent()
606 || self.is_raid()
607 || self.is_ror()
608 || self.is_url()
609 }
610 pub fn as_str(&self) -> &'static str {
612 match self {
613 | Self::DOI => "doi",
614 | Self::ARXIV => "arxiv",
615 | Self::ISBN => "isbn",
616 | Self::ORCID => "orcid",
617 | Self::Patent => "patent",
618 | Self::PIDINST => "pidinst",
619 | Self::ARK => "ark",
620 | Self::RAID => "raid",
621 | Self::ROR => "ror",
622 | Self::URL => "url",
623 | _ => "unknown",
624 }
625 }
626 pub fn is_project_identifier(&self) -> bool {
628 self.is_doi() || self.is_arxiv() || self.is_raid() || self.is_isbn() || self.is_patent() || self.is_ark()
629 }
630}
631impl fmt::Display for PID {
632 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
633 formatter.write_str(self.as_str())
634 }
635}
636impl Serialize for PID {
637 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
638 where
639 S: serde::Serializer,
640 {
641 serializer.serialize_str(self.as_str())
642 }
643}
644impl<'de> Deserialize<'de> for PID {
645 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
646 where
647 D: serde::Deserializer<'de>,
648 {
649 String::deserialize(deserializer).and_then(|value| {
650 Self::iter()
651 .find(|pid| pid.as_str().eq_ignore_ascii_case(&value))
652 .ok_or_else(|| serde::de::Error::custom(format!("unknown PID type `{value}`")))
653 })
654 }
655}
656impl From<&str> for PID {
657 fn from(value: &str) -> Self {
658 Self::iter()
659 .find(|pid| pid.as_str().eq_ignore_ascii_case(value.trim()))
660 .unwrap_or_default()
661 }
662}
663impl Betanumeric for char {
664 fn is_betanumeric(&self) -> bool {
665 BETANUMERIC_DIGITS.contains(*self)
666 }
667 fn to_betanumeric_ordinal(&self) -> Option<usize> {
668 BETANUMERIC_DIGITS.chars().position(|x| x.eq(self))
669 }
670}
671impl Default for ARK {
672 fn default() -> Self {
673 Self::new()
674 }
675}
676impl Default for DOI {
677 fn default() -> Self {
678 Self::new()
679 }
680}
681impl Default for ARXIV {
682 fn default() -> Self {
683 Self::new()
684 }
685}
686impl Default for ORCID {
687 fn default() -> Self {
688 Self::new()
689 }
690}
691impl Default for RAID {
692 fn default() -> Self {
693 Self::new()
694 }
695}
696impl Default for ROR {
697 fn default() -> Self {
698 Self::new()
699 }
700}
701impl fmt::Display for ARK {
702 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704 let nma = self.name_mapping_authority.clone().unwrap_or_default().trim_end_matches('/').to_string();
705 let identifier = self.identifier();
706 let result = [nma, identifier].into_iter().filter(|x| !x.is_empty()).collect::<Vec<String>>().join("/");
707 write!(f, "{result}")
708 }
709}
710impl fmt::Display for DOI {
711 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
713 let result = self.identifier();
714 write!(f, "{result}")
715 }
716}
717impl fmt::Display for ARXIV {
718 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
719 write!(f, "{}", self.identifier())
720 }
721}
722impl fmt::Display for ISBN {
723 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
725 let result = self.identifier();
726 write!(f, "{result}")
727 }
728}
729impl fmt::Display for ORCID {
730 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732 let schema_uri = self.schema_uri();
733 let identifier = self.identifier();
734 let uri = if schema_uri.is_empty() { DEFAULT_ORCID_SCHEMA_URI } else { &schema_uri };
735 let values = match &self.identifier {
736 | Some(_) => [uri, &identifier].to_vec(),
737 | None => vec![],
738 };
739 let result = values
740 .into_iter()
741 .filter(|x| !x.is_empty())
742 .map(String::from)
743 .collect::<Vec<String>>()
744 .join("/");
745 write!(f, "{result}")
746 }
747}
748impl fmt::Display for RAID {
749 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
751 let result = self.identifier();
752 write!(f, "{result}")
753 }
754}
755impl fmt::Display for ROR {
756 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
758 let schema_uri = self.schema_uri();
759 let result = self.identifier();
760 if result.is_empty() {
761 write!(f, "")
762 } else {
763 write!(f, "{schema_uri}{result}")
764 }
765 }
766}
767impl PersistentIdentifier for ARK {
768 fn new() -> Self {
769 ARK::init().build()
770 }
771 fn schema_uri(&self) -> String {
772 let uri = match &self.name_mapping_authority {
773 | Some(value) => value,
774 | None => "",
775 };
776 uri.trim_end_matches("/").to_string()
777 }
778 fn identifier(&self) -> String {
779 let values = [self.prefix(), self.suffix()];
780 values
781 .iter()
782 .flatten()
783 .filter(|x| !x.is_empty())
784 .map(String::from)
785 .collect::<Vec<String>>()
786 .join("/")
787 }
788 fn prefix(&self) -> Option<String> {
789 match (self.name_assigning_authority_number.as_ref(), self.assigned_name.as_ref()) {
790 | (Some(naan), Some(name)) => Some(format!("{}{}/{}", self.label.trim_end_matches('/'), naan, name)),
791 | _ => None,
792 }
793 }
794 fn suffix(&self) -> Option<String> {
795 let parts = self.parts.join("/");
796 let variants = self.variants.join(".");
797 let qualifiers = [parts, variants];
798 let result = qualifiers
799 .iter()
800 .filter(|x| !x.is_empty())
801 .map(String::from)
802 .collect::<Vec<String>>()
803 .join(".");
804 Some(result)
805 }
806 fn check_digit(&self) -> Option<Vec<char>> {
807 let Self {
808 name_assigning_authority_number: naan,
809 assigned_name: name,
810 ..
811 } = self;
812 let values = [naan.clone(), name.clone()];
813 if values.iter().all(|x| x.is_some()) {
814 let value = values.iter().flatten().map(String::from).collect::<Vec<String>>().join("/");
815 if value.is_empty() {
816 None
817 } else {
818 let trimmed = value.get(..value.len().saturating_sub(1)).unwrap_or_default();
819 noid_check_digit(trimmed)
820 }
821 } else {
822 None
823 }
824 }
825}
826impl PersistentIdentifier for DOI {
827 fn new() -> Self {
828 DOI::init().build()
829 }
830 fn schema_uri(&self) -> String {
831 self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
832 }
833 fn identifier(&self) -> String {
834 let values = [self.prefix(), self.suffix()];
835 values
836 .iter()
837 .flatten()
838 .filter(|x| !x.is_empty())
839 .map(String::from)
840 .collect::<Vec<String>>()
841 .join("/")
842 }
843 fn prefix(&self) -> Option<String> {
845 let values = [
846 self.directory_indicator.as_ref().cloned().unwrap_or_default(),
847 self.registrant_code.as_ref().cloned().unwrap_or_default(),
848 ];
849 let result = values
850 .iter()
851 .filter(|x| !x.is_empty())
852 .map(String::from)
853 .collect::<Vec<String>>()
854 .join(".");
855 Some(result)
856 }
857 fn suffix(&self) -> Option<String> {
859 fn postprocess(mut value: String) -> String {
860 if value.ends_with(".") {
861 value.pop();
862 }
863 value
864 }
865 let result = self.suffix.as_ref().cloned().unwrap_or_default();
866 if !result.is_empty() {
867 Some(postprocess(result))
868 } else {
869 None
870 }
871 }
872 fn url(&self) -> String {
873 let identifier = self.identifier();
874 if identifier.is_empty() {
875 String::new()
876 } else {
877 let uri = self.schema_uri();
878 let schema = if uri.is_empty() { DEFAULT_DOI_SCHEMA_URI } else { &uri };
879 format!("{}/{}", schema, identifier)
880 }
881 }
882}
883impl PersistentIdentifier for ARXIV {
884 fn new() -> Self {
885 ARXIV::init().build()
886 }
887 fn schema_uri(&self) -> String {
888 self.schema_uri
889 .as_ref()
890 .map(|value| value.trim_end_matches('/').to_string())
891 .unwrap_or_else(|| DEFAULT_ARXIV_SCHEMA_URI.to_string())
892 }
893 fn identifier(&self) -> String {
894 let work = self.work_identifier();
895 match (work.is_empty(), self.version.as_ref()) {
896 | (false, Some(version)) => format!("{work}{version}"),
897 | _ => work,
898 }
899 }
900 fn prefix(&self) -> Option<String> {
901 self.archive.clone().or_else(|| {
902 self.identifier
903 .as_ref()
904 .and_then(|value| value.split_once('.').map(|(prefix, _)| prefix.to_string()))
905 })
906 }
907 fn suffix(&self) -> Option<String> {
908 self.identifier.clone()
909 }
910 fn url(&self) -> String {
911 self.identifier()
912 .strip_prefix("arXiv:")
913 .map(|identifier| format!("{}/abs/{identifier}", self.schema_uri()))
914 .unwrap_or_default()
915 }
916}
917impl ARXIV {
918 pub fn work_identifier(&self) -> String {
920 self.identifier.as_ref().map_or_else(String::new, |identifier| {
921 self.archive
922 .as_ref()
923 .map_or_else(|| format!("arXiv:{identifier}"), |archive| format!("arXiv:{archive}/{identifier}"))
924 })
925 }
926}
927impl PersistentIdentifier for ISBN {
928 fn new() -> Self {
929 ISBN::init().build()
930 }
931 fn schema_uri(&self) -> String {
932 "".to_string()
933 }
934 fn identifier(&self) -> String {
935 let ISBN {
936 prefix_element,
937 registration_group,
938 publisher,
939 title,
940 check_digit,
941 } = self;
942 [prefix_element, registration_group, publisher, title, check_digit]
943 .into_iter()
944 .map(|x| x.clone().unwrap_or_default())
945 .collect::<Vec<String>>()
946 .join("-")
947 }
948 fn prefix(&self) -> Option<String> {
951 let ISBN {
952 prefix_element,
953 registration_group,
954 publisher,
955 ..
956 } = self;
957 let result = format!(
958 "{}.{}{}",
959 prefix_element.clone().unwrap_or_default(),
960 registration_group.clone().unwrap_or_default(),
961 publisher.clone().unwrap_or_default()
962 );
963 Some(result)
964 }
965 fn suffix(&self) -> Option<String> {
968 let ISBN { title, check_digit, .. } = self;
969 let result = [title, check_digit]
970 .into_iter()
971 .map(|x| x.clone().unwrap_or_default())
972 .collect::<Vec<String>>()
973 .join("");
974 Some(result)
975 }
976 fn check_digit(&self) -> Option<Vec<char>> {
977 isbn_check_digit(self.identifier())
978 }
979}
980impl From<ISBN> for DOI {
981 fn from(isbn: ISBN) -> Self {
982 DOI::init()
983 .schema_uri(DEFAULT_DOI_SCHEMA_URI)
984 .directory_indicator(DATACITE_DOI_DIRECTORY_INDICATOR)
985 .maybe_registrant_code(isbn.prefix())
986 .maybe_suffix(isbn.suffix())
987 .build()
988 }
989}
990impl From<ARXIV> for DOI {
991 fn from(arxiv: ARXIV) -> Self {
992 let suffix = arxiv.work_identifier().trim_start_matches("arXiv:").to_string();
993 DOI::init()
994 .schema_uri(DEFAULT_DOI_SCHEMA_URI)
995 .directory_indicator(DATACITE_DOI_DIRECTORY_INDICATOR)
996 .registrant_code(ARXIV_DATACITE_REGISTRANT_CODE)
997 .suffix(format!("arXiv.{suffix}"))
998 .build()
999 }
1000}
1001impl TryFrom<DOI> for ARXIV {
1002 type Error = ValidationError;
1003 fn try_from(doi: DOI) -> Result<Self, Self::Error> {
1004 let suffix = doi.suffix().unwrap_or_default();
1005 let arxiv_suffix = suffix
1006 .get(..6)
1007 .filter(|prefix| prefix.eq_ignore_ascii_case("arxiv."))
1008 .and_then(|_| suffix.get(6..));
1009 match (doi.directory_indicator.as_deref(), doi.registrant_code.as_deref(), arxiv_suffix) {
1010 | (Some(DATACITE_DOI_DIRECTORY_INDICATOR), Some(ARXIV_DATACITE_REGISTRANT_CODE), Some(identifier)) => {
1011 let arxiv = ARXIV::from_string(format!("arXiv:{identifier}"));
1012 match ARXIV::is_valid(arxiv.to_string()) {
1013 | true => Ok(arxiv),
1014 | false => Err(ValidationError::new("arxiv_doi")),
1015 }
1016 }
1017 | _ => Err(ValidationError::new("arxiv_doi")),
1018 }
1019 }
1020}
1021impl From<DOI> for ISBN {
1022 fn from(doi: DOI) -> Self {
1023 let prefix = doi.prefix().unwrap_or_default().replace(".", "-");
1024 let suffix = match doi.suffix() {
1025 | Some(value) => {
1026 let check_digit = value.chars().last().unwrap_or_default().to_string();
1027 let title = value.get(..value.len().saturating_sub(1)).unwrap_or_default().to_string();
1028 format!("{title}-{check_digit}")
1029 }
1030 | None => "".to_string(),
1031 };
1032 let result = format!("{}-{suffix}", prefix.trim_start_matches("10-"));
1033 ISBN::from_string(result)
1034 }
1035}
1036impl PersistentIdentifier for ORCID {
1037 fn new() -> Self {
1038 ORCID::init().build()
1039 }
1040 fn schema_uri(&self) -> String {
1044 self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
1045 }
1046 fn identifier(&self) -> String {
1051 let stripped = self.identifier.as_ref().cloned().unwrap_or_default().replace("-", "");
1052 stripped.chunk(4).join("-")
1053 }
1054 fn suffix(&self) -> Option<String> {
1055 Some(self.identifier())
1056 }
1057 fn check_digit(&self) -> Option<Vec<char>> {
1058 orcid_check_digit(self.identifier())
1059 }
1060}
1061impl PersistentIdentifier for RAID {
1062 fn new() -> Self {
1063 RAID::init().build()
1064 }
1065 fn schema_uri(&self) -> String {
1066 self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
1067 }
1068 fn prefix(&self) -> Option<String> {
1069 self.prefix.clone()
1070 }
1071 fn suffix(&self) -> Option<String> {
1072 self.suffix.clone()
1073 }
1074 fn identifier(&self) -> String {
1075 let values = [self.prefix(), self.suffix()];
1076 values
1077 .iter()
1078 .flatten()
1079 .filter(|x| !x.is_empty())
1080 .map(String::from)
1081 .collect::<Vec<String>>()
1082 .join("/")
1083 }
1084}
1085impl PersistentIdentifier for ROR {
1086 fn new() -> Self {
1087 ROR::init().build()
1088 }
1089 fn schema_uri(&self) -> String {
1090 let processed = self
1091 .schema_uri
1092 .as_ref()
1093 .cloned()
1094 .unwrap_or_else(|| DEFAULT_ROR_SCHEMA_URI.to_string())
1095 .trim_end_matches("/")
1096 .replace(" ", "")
1097 .to_string();
1098 format!("{processed}/")
1099 }
1100 fn identifier(&self) -> String {
1101 self.identifier.clone().unwrap_or_default()
1102 }
1103 fn suffix(&self) -> Option<String> {
1104 self.identifier.clone()
1105 }
1106 fn check_digit(&self) -> Option<Vec<char>> {
1107 self.identifier().get(1..).and_then(ror_check_digit)
1108 }
1109}
1110impl<T: AsRef<str>> PersistentIdentifierConvert<T> for T
1111where
1112 T: ToString,
1113{
1114 fn format_as(&self, pid_type: PID) -> String {
1115 match pid_type {
1116 | PID::ARK => ARK::format(self.as_ref()),
1117 | PID::ARXIV => ARXIV::format(self.as_ref()),
1118 | PID::DOI => DOI::format(self.as_ref()),
1119 | PID::ORCID => ORCID::format(self.as_ref()),
1120 | PID::RAID => RAID::format(self.as_ref()),
1121 | PID::ROR => <ROR as PersistentIdentifierParse>::format(self.as_ref()),
1122 | _ => self.as_ref().to_string(),
1123 }
1124 }
1125 fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal {
1126 let value = self.as_ref().to_string();
1127 match pid_type {
1128 | PID::ARK => PersistentIdentifierInternal { value, pid_type: PID::ARK },
1129 | PID::ARXIV => PersistentIdentifierInternal { value, pid_type: PID::ARXIV },
1130 | PID::DOI => PersistentIdentifierInternal { value, pid_type: PID::DOI },
1131 | PID::ORCID => PersistentIdentifierInternal { value, pid_type: PID::ORCID },
1132 | PID::RAID => PersistentIdentifierInternal { value, pid_type: PID::RAID },
1133 | PID::ROR => PersistentIdentifierInternal { value, pid_type: PID::ROR },
1134 | _ => PersistentIdentifierInternal::default(),
1135 }
1136 }
1137 fn is_pid(&self, pid_type: PID) -> bool {
1138 match pid_type {
1139 | PID::ARK => self.is_ark(),
1140 | PID::ARXIV => self.is_arxiv(),
1141 | PID::DOI => self.is_doi(),
1142 | PID::ORCID => self.is_orcid(),
1143 | PID::RAID => self.is_raid(),
1144 | PID::ROR => self.is_ror(),
1145 | _ => false,
1146 }
1147 }
1148 fn is_ark(&self) -> bool {
1149 ARK::is_valid(self.as_ref())
1150 }
1151 fn is_arxiv(&self) -> bool {
1152 ARXIV::is_valid(self.as_ref())
1153 }
1154 fn is_doi(&self) -> bool {
1155 DOI::is_valid(self.as_ref())
1156 }
1157 fn is_isbn(&self) -> bool {
1158 ISBN::is_valid(self.as_ref())
1159 }
1160 fn is_orcid(&self) -> bool {
1161 ORCID::is_valid(self.as_ref())
1162 }
1163 fn is_raid(&self) -> bool {
1164 RAID::is_valid(self.as_ref())
1165 }
1166 fn is_ror(&self) -> bool {
1167 ROR::is_valid(self.as_ref())
1168 }
1169}
1170impl PersistentIdentifierInternal {
1171 pub fn to_ark(&self) -> ARK {
1173 let PersistentIdentifierInternal { value, pid_type } = self;
1174 match pid_type {
1175 | PID::ARK => ARK::from_string(value),
1176 | _ => ARK::default(),
1177 }
1178 }
1179 pub fn to_arxiv(&self) -> ARXIV {
1181 let PersistentIdentifierInternal { value, pid_type } = self;
1182 match pid_type {
1183 | PID::ARXIV => ARXIV::from_string(value),
1184 | _ => ARXIV::default(),
1185 }
1186 }
1187 pub fn to_doi(&self) -> DOI {
1189 let PersistentIdentifierInternal { value, pid_type } = self;
1190 match pid_type {
1191 | PID::DOI => DOI::from_string(value),
1192 | _ => DOI::default(),
1193 }
1194 }
1195 pub fn to_orcid(&self) -> ORCID {
1197 let PersistentIdentifierInternal { value, pid_type } = self;
1198 match pid_type {
1199 | PID::ORCID => ORCID::from_string(value),
1200 | _ => ORCID::default(),
1201 }
1202 }
1203 pub fn to_raid(&self) -> RAID {
1205 let PersistentIdentifierInternal { value, pid_type } = self;
1206 match pid_type {
1207 | PID::RAID => RAID::from_string(value),
1208 | _ => RAID::default(),
1209 }
1210 }
1211 pub fn to_ror(&self) -> ROR {
1213 let PersistentIdentifierInternal { value, pid_type } = self;
1214 match pid_type {
1215 | PID::ROR => ROR::from_string(value),
1216 | _ => ROR::default(),
1217 }
1218 }
1219}
1220impl PersistentIdentifierParse for ARK {
1221 fn find_all(value: impl ToString) -> Vec<Self> {
1223 let re = &RE_ARK;
1224 re.find_iter(&value.to_string())
1225 .filter_map(Result::ok)
1226 .map(|m| ARK::from_string(m.as_str()))
1227 .collect()
1228 }
1229 fn format(value: impl ToString) -> String {
1239 ARK::from_string(value.to_string()).to_string()
1240 }
1241 fn from_string(value: impl ToString) -> Self {
1250 let groups = ["nma", "label", "naan", "assigned_name", "parts", "variants"];
1251 let pattern = format!("^{RE_ARK_TEXT}$");
1252 let text = value.to_string();
1253 let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1254 let parts = match lookup.get("parts") {
1255 | Some(value) => value.split('/').map(String::from).collect(),
1256 | None => vec![],
1257 };
1258 let variants = match lookup.get("variants") {
1259 | Some(value) => value.split('.').map(String::from).collect(),
1260 | None => vec![],
1261 };
1262 ARK::init()
1263 .maybe_assigned_name(lookup.get("assigned_name").cloned())
1264 .maybe_label(lookup.get("label").cloned())
1265 .maybe_name_assigning_authority_number(lookup.get("naan").cloned())
1266 .maybe_name_mapping_authority(lookup.get("nma").cloned())
1267 .parts(parts)
1268 .variants(variants)
1269 .build()
1270 }
1271 fn is_valid(value: impl ToString) -> bool {
1287 let pid = ARK::from_string(value);
1288 let naan = pid.name_assigning_authority_number.unwrap_or_default();
1289 let naan_is_betanumeric = naan.chars().all(|x| x.is_betanumeric());
1290 let shoulder_starts_with_lowercase_letter = match pid.assigned_name {
1291 | Some(value) => match value.chars().next() {
1292 | Some(value) => value.is_ascii_lowercase() && !value.eq(&'l'),
1293 | None => false,
1294 },
1295 | None => false,
1296 };
1297 !naan.is_empty() && naan_is_betanumeric && shoulder_starts_with_lowercase_letter
1298 }
1299}
1300impl PersistentIdentifierParse for ARXIV {
1301 fn find_all(value: impl ToString) -> Vec<Self> {
1302 RE_ARXIV
1303 .find_iter(&value.to_string())
1304 .filter_map(Result::ok)
1305 .map(|matched| matched.as_str().to_string())
1306 .filter(|value| Self::is_valid(value))
1307 .map(Self::from_string)
1308 .collect()
1309 }
1310 fn format(value: impl ToString) -> String {
1311 Self::from_string(value).to_string()
1312 }
1313 fn from_string(value: impl ToString) -> Self {
1314 let groups = ["schema_uri", "resource", "archive", "identifier", "version", "pdf"];
1315 let pattern = format!("^{RE_ARXIV_TEXT}$");
1316 let text = value.to_string();
1317 let lookup = regex_capture_lookup(pattern.as_str(), text.as_str(), groups.to_vec());
1318 Self::init()
1319 .maybe_schema_uri(lookup.get("schema_uri").map(|_| DEFAULT_ARXIV_SCHEMA_URI.to_string()))
1320 .maybe_archive(lookup.get("archive").map(|value| value.to_ascii_lowercase()))
1321 .maybe_identifier(lookup.get("identifier").cloned())
1322 .maybe_version(lookup.get("version").map(|value| value.to_ascii_lowercase()))
1323 .build()
1324 }
1325 fn is_valid(value: impl ToString) -> bool {
1329 let value = value.to_string();
1330 let complete_match = RE_ARXIV
1332 .find(&value)
1333 .ok()
1334 .flatten()
1335 .is_some_and(|matched| matched.start() == 0 && matched.end() == value.len());
1336 let parsed = Self::from_string(&value);
1337 let resource_is_valid = match (value.to_ascii_lowercase().contains("/abs/"), value.to_ascii_lowercase().ends_with(".pdf")) {
1339 | (true, true) => false,
1340 | (false, true) => value.to_ascii_lowercase().contains("/pdf/"),
1341 | _ => true,
1342 };
1343 let components_are_valid = parsed.identifier.as_deref().is_some_and(|identifier| {
1344 let valid_date = |date: &str| {
1345 let year = date.get(..2).and_then(|value| value.parse::<u16>().ok());
1346 let month = date.get(2..).and_then(|value| value.parse::<u8>().ok());
1347 year.zip(month).filter(|(_, month)| (1..=12).contains(month))
1348 };
1349 match (parsed.archive.as_deref(), identifier.split_once('.')) {
1350 | (None, Some((date, sequence))) => valid_date(date).is_some_and(|(year, month)| {
1352 let yymm = year.saturating_mul(100).saturating_add(u16::from(month));
1353 let width_is_valid = matches!(yymm, 704..=1412) && sequence.len() == 4 || yymm >= 1501 && sequence.len() == 5;
1354 width_is_valid && sequence != "0000" && sequence != "00000"
1355 }),
1356 | (Some(_), None) if identifier.len() == 7 => valid_date(identifier.get(..4).unwrap_or_default()).is_some_and(|(year, month)| {
1358 let date_is_legacy = year > 91 || year == 91 && month >= 7 || year < 7 || year == 7 && month <= 3;
1359 date_is_legacy && identifier.get(4..).is_some_and(|sequence| sequence != "000")
1360 }),
1361 | _ => false,
1362 }
1363 });
1364 complete_match && resource_is_valid && components_are_valid
1365 }
1366}
1367impl PersistentIdentifierParse for DOI {
1368 fn find_all(value: impl ToString) -> Vec<Self> {
1370 let re = &RE_DOI;
1371 re.find_iter(&value.to_string())
1372 .filter_map(Result::ok)
1373 .map(|m| DOI::from_string(trim_unmatched_trailing_parentheses(m.as_str())))
1374 .collect()
1375 }
1376 fn format(value: impl ToString) -> String {
1385 DOI::from_string(value).to_string()
1386 }
1387 fn from_string(value: impl ToString) -> Self {
1397 let groups = ["schema_uri", "directory_indicator", "prefix_element", "registrant_code", "suffix"];
1398 let pattern = format!("^{RE_DOI_TEXT}$");
1399 let text = value.to_string();
1400 let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1401 DOI::init()
1402 .maybe_schema_uri(lookup.get("schema_uri").cloned())
1403 .maybe_directory_indicator(lookup.get("directory_indicator").cloned())
1404 .maybe_registrant_code(lookup.get("registrant_code").cloned())
1405 .maybe_suffix(lookup.get("suffix").cloned())
1406 .build()
1407 }
1408 fn is_valid(value: impl ToString) -> bool {
1424 let pid = DOI::from_string(value.to_string());
1425 let prefix_is_valid = match pid.prefix() {
1426 | Some(x) => is_numeric(&x.replace(".", "")) && !x.eq("10.5555"),
1427 | _ => false,
1428 };
1429 let suffix_is_valid = pid.suffix().is_some();
1430 prefix_is_valid && suffix_is_valid
1431 }
1432}
1433impl PersistentIdentifierParse for ISBN {
1434 fn find_all(value: impl ToString) -> Vec<Self> {
1436 let re = &RE_ISBN;
1437 re.find_iter(&value.to_string())
1438 .filter_map(Result::ok)
1439 .map(|m| ISBN::from_string(m.as_str()))
1440 .collect()
1441 }
1442 fn format(value: impl ToString) -> String {
1444 ISBN::from_string(value).to_string()
1445 }
1446 fn from_string(value: impl ToString) -> Self {
1455 let groups = ["prefix_element", "registration_group", "publisher", "title", "check_digit"];
1456 let pattern = format!("^{RE_ISBN_TEXT}$");
1457 let text = value.to_string();
1458 let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1459 ISBN::init()
1460 .maybe_prefix_element(lookup.get("prefix_element").cloned())
1461 .maybe_registration_group(lookup.get("registration_group").cloned())
1462 .maybe_publisher(lookup.get("publisher").cloned())
1463 .maybe_title(lookup.get("title").cloned())
1464 .maybe_check_digit(lookup.get("check_digit").cloned())
1465 .build()
1466 }
1467 fn is_valid(value: impl ToString) -> bool {
1480 let pid = ISBN::from_string(value.to_string());
1481 let last = value.to_string().chars().last().unwrap_or_default();
1482 let has_valid_check_digit = match pid.check_digit() {
1483 | Some(chars) => chars.contains(&last),
1484 | _ => false,
1485 };
1486 let is_valid_length = value.to_string().replace("-", "").len() == 13;
1487 has_valid_check_digit && is_valid_length
1488 }
1489}
1490impl PersistentIdentifierParse for ORCID {
1491 fn find_all(value: impl ToString) -> Vec<Self> {
1493 let re = &RE_ORCID;
1494 re.find_iter(&value.to_string())
1495 .filter_map(Result::ok)
1496 .map(|m| ORCID::from_string(m.as_str()))
1497 .collect()
1498 }
1499 fn format(value: impl ToString) -> String {
1508 ORCID::from_string(value).to_string()
1509 }
1510 fn from_string(value: impl ToString) -> Self {
1519 let groups = ["schema_uri", "identifier", "check_digit"];
1520 let pattern = format!("^{RE_ORCID_TEXT}$");
1521 let text = value.to_string();
1522 let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1523 ORCID::init()
1524 .maybe_schema_uri(lookup.get("schema_uri").cloned())
1525 .maybe_identifier(lookup.get("identifier").cloned())
1526 .maybe_check_digit(lookup.get("check_digit").cloned())
1527 .build()
1528 }
1529 fn is_valid(value: impl ToString) -> bool {
1546 let pid = ORCID::from_string(value.to_string());
1547 let identifier = pid.identifier();
1548 let last = identifier.chars().last().unwrap_or_default();
1549 match orcid_check_digit(identifier.as_str()) {
1550 | Some(check_digit) => {
1551 if check_digit.contains(&last) {
1552 identifier.len() == 19
1553 } else {
1554 false
1555 }
1556 }
1557 | _ => false,
1558 }
1559 }
1560}
1561impl PersistentIdentifierParse for RAID {
1562 fn find_all(value: impl ToString) -> Vec<Self> {
1564 let re = &RE_DOI;
1565 re.find_iter(&value.to_string())
1566 .filter_map(Result::ok)
1567 .map(|m| RAID::from_string(trim_unmatched_trailing_parentheses(m.as_str())))
1568 .collect()
1569 }
1570 fn format(value: impl ToString) -> String {
1578 RAID::from_string(value).to_string()
1579 }
1580 fn from_string(value: impl ToString) -> Self {
1584 let groups = ["schema_uri", "directory_indicator", "registrant_code", "suffix"];
1585 let pattern = format!("^{RE_RAID_TEXT}$");
1586 let text = value.to_string();
1587 let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1588 let directory_indicator = lookup.get("directory_indicator").cloned();
1589 let registrant_code = lookup.get("registrant_code").cloned();
1590 let prefix = [directory_indicator, registrant_code]
1591 .into_iter()
1592 .flatten()
1593 .collect::<Vec<String>>()
1594 .join(".");
1595 RAID::init()
1596 .prefix(prefix)
1597 .maybe_schema_uri(lookup.get("schema_uri").cloned())
1598 .maybe_suffix(lookup.get("suffix").cloned())
1599 .build()
1600 }
1601 fn is_valid(value: impl ToString) -> bool {
1604 let pid = RAID::from_string(value.to_string());
1605 let prefix_is_valid = match pid.prefix() {
1606 | Some(x) => is_numeric(&x.replace(".", "")) && !x.eq("10.5555"),
1607 | _ => false,
1608 };
1609 let suffix_is_valid = pid.suffix().is_some();
1610 prefix_is_valid && suffix_is_valid
1611 }
1612}
1613impl PersistentIdentifierParse for ROR {
1614 fn find_all(value: impl ToString) -> Vec<Self> {
1616 let re = &RE_ROR;
1617 re.find_iter(&value.to_string())
1618 .filter_map(Result::ok)
1619 .filter(|value| ROR::is_valid(value.as_str()))
1620 .map(|m| ROR::from_string(m.as_str()))
1621 .collect()
1622 }
1623 fn format(value: impl ToString) -> String {
1632 ROR::from_string(value.to_string()).to_string()
1633 }
1634 fn from_string(value: impl ToString) -> Self {
1643 let groups = ["schema_uri", "identifier", "check_digit"];
1644 let pattern = format!("^{RE_ROR_TEXT}$");
1645 let text = value.to_string();
1646 let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1647 ROR::init()
1648 .maybe_schema_uri(lookup.get("schema_uri").cloned())
1649 .maybe_identifier(lookup.get("identifier").cloned())
1650 .maybe_check_digit(lookup.get("check_digit").cloned())
1651 .build()
1652 }
1653 fn is_valid(value: impl ToString) -> bool {
1669 let pid = ROR::from_string(value.to_string());
1670 let identifier = pid.identifier();
1671 let last_two = identifier.chars().rev().take(2).collect::<String>().chars().rev().collect::<String>();
1672 if identifier.is_empty() {
1673 false
1674 } else {
1675 match ror_check_digit(&identifier[1..]) {
1676 | Some(check_digit) => {
1677 if identifier.len() == 9 {
1678 let calculated_last_two = check_digit.iter().collect::<String>();
1679 calculated_last_two == last_two
1680 } else {
1681 false
1682 }
1683 }
1684 | _ => false,
1685 }
1686 }
1687 }
1688}
1689#[allow(clippy::arithmetic_side_effects)]
1694pub fn isbn_check_digit<S>(_value: S) -> Option<Vec<char>>
1695where
1696 S: AsRef<str>,
1697{
1698 const MODULUS: u32 = 10;
1699 let working = _value.as_ref().replace("-", "");
1700 let sum = working.chars().take(12).enumerate().fold(0, |acc, (index, x)| {
1701 let digit = x.to_digit(10).unwrap_or_default();
1702 let multiplier = if index % 2 == 0 { 1 } else { 3 };
1703 acc + (digit * multiplier)
1704 });
1705 let remainder = sum % MODULUS;
1706 let result = if remainder == 0 { 0 } else { MODULUS - remainder };
1707 char::from_digit(result, 10).map(|c| vec![c])
1708}
1709#[allow(clippy::arithmetic_side_effects)]
1721pub fn noid_check_digit<S>(value: S) -> Option<Vec<char>>
1722where
1723 S: AsRef<str>,
1724{
1725 const RADIX: usize = 29;
1726 let sum = value.as_ref().chars().enumerate().fold(0, |acc, (i, val)| {
1727 let position = i + 1;
1728 let ordinal = val.to_betanumeric_ordinal().unwrap_or(0);
1729 acc + (position * ordinal)
1730 });
1731 let remainder = sum % RADIX;
1732 to_betanumeric(remainder as u8).map(|c| vec![c])
1733}
1734#[allow(clippy::arithmetic_side_effects)]
1746pub fn orcid_check_digit<S>(value: S) -> Option<Vec<char>>
1747where
1748 S: AsRef<str>,
1749{
1750 const MODULUS: u32 = 11;
1751 const RADIX: u32 = 2;
1752 let working = value.as_ref().replace("-", "").replace(" ", "");
1753 let sum = working.chars().take(15).fold(0, |acc, x| {
1754 let digit = x.to_digit(10).unwrap_or_default();
1755 (acc + digit) * RADIX
1756 });
1757 let remainder = sum % MODULUS;
1758 let result = (MODULUS + 1 - remainder) % MODULUS;
1759 if result == 10 {
1760 Some(vec!['X'])
1761 } else {
1762 char::from_digit(result, 10).map(|c| vec![c])
1763 }
1764}
1765#[allow(clippy::arithmetic_side_effects)]
1779pub fn ror_check_digit<S>(value: S) -> Option<Vec<char>>
1780where
1781 S: AsRef<str>,
1782{
1783 const MODULUS: u128 = 97;
1784 let working = value
1785 .as_ref()
1786 .replace("-", "")
1787 .replace(" ", "")
1788 .chars()
1789 .take(6)
1790 .map(String::from)
1791 .collect::<Vec<_>>()
1792 .join("");
1793 match base32_crockford_decode(working) {
1794 | Some(value) => {
1795 let remainder = (value * 100) % MODULUS;
1796 let checksum = (MODULUS + 1 - remainder) % MODULUS;
1797 let result = if checksum < 10 {
1798 format!("0{}", checksum).chars().collect()
1799 } else {
1800 checksum.to_string().chars().collect()
1801 };
1802 Some(result)
1803 }
1804 | None => None,
1805 }
1806}
1807fn to_betanumeric(value: u8) -> Option<char> {
1808 match BETANUMERIC_DIGITS.chars().enumerate().find(|(i, _)| *i == value as usize) {
1809 | Some((_, x)) => Some(x),
1810 | None => None,
1811 }
1812}
1813fn is_numeric(value: &str) -> bool {
1814 value.chars().all(|x| x.is_numeric())
1815}
1816
1817#[cfg(test)]
1818mod tests;