Skip to main content

acorn/schema/pid/
mod.rs

1//! Persistent Identifiers (PID)
2//!
3//! Contains functions for working with persistent identifiers (PID) such as [`ORCID`], [`DOI`]s, and [RAiD](`raid`)s
4//!
5//! ### Features
6//! - Best in class validation
7//! - Convert persistent identifiers into standard formats
8//! - Access the sub parts of a persistent identifier
9//!
10//! [RAiDs]: https://www.raid.org/
11use 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
39/// Add utility functions for working with beta numeric values
40///
41/// Mostly intended for working with [NCDA](`noid_check_digit`)
42pub trait Betanumeric {
43    /// Check if `self` is a betanumeric value
44    fn is_betanumeric(&self) -> bool {
45        false
46    }
47    /// Convert `self` into a betanumeric ordinal value
48    /// ### Example
49    /// > `w` -> `26`
50    fn to_betanumeric_ordinal(&self) -> Option<usize>;
51}
52/// Provides common functions for working with persistent identifiers (PID)
53pub trait PersistentIdentifier: fmt::Display {
54    /// Create a new PID
55    fn new() -> Self;
56    /// Get standardized form of schema URI for a PID
57    /// ### Examples
58    /// - `https://doi.org`
59    /// - `https://orcid.org`
60    fn schema_uri(&self) -> String;
61    /// Get PID identifier section
62    /// ### Examples
63    /// - `ark:1234/x5678` for [`ARK`]
64    /// - `10.1234/5678` for [`DOI`]
65    /// - `0000-0002-2057-9115` for [`ORCID`]
66    fn identifier(&self) -> String;
67    /// Get PID prefix (different interpretation depending on PID type)
68    ///
69    /// Not every PID type has a prefix, but generally every PID has a "first" part that can losely be considered a "prefix"
70    fn prefix(&self) -> Option<String> {
71        None
72    }
73    /// Get PID suffix (different interpretation depending on PID type)
74    ///
75    /// Not every PID type has a suffix, but generally every PID has a "second" part that can losely be considered a "suffix"
76    fn suffix(&self) -> Option<String>;
77    /// Get PID check digit (when applicable)
78    fn check_digit(&self) -> Option<Vec<char>> {
79        None
80    }
81    /// Get fully resolved URL of the PID with its schema URI
82    fn url(&self) -> String {
83        String::new()
84    }
85}
86/// Add coercion to persistent identifier (PID) functionality to string values
87pub trait PersistentIdentifierConvert<T: AsRef<str>> {
88    /// Convert `self` into a string standard format PID of a certain type
89    /// ```ignore
90    /// use acorn::schema::pid::{PID, PersistentIdentifier};
91    ///
92    /// assert_eq!("https://doi.org/10.1234/5678".format_as(PID::DOI), "10.1234/5678");
93    /// assert_eq!("0000-0002-2057-9115".format_as(PID::ORCID), "https://orcid.org/0000-0002-2057-9115");
94    /// ```
95    fn format_as(&self, pid_type: PID) -> String;
96    /// Coerce `self` into given PID type.
97    /// ```ignore
98    /// use acorn::schema::pid::{PID, PersistentIdentifier};
99    ///
100    /// let doi = "https://doi.org/10.1234/5678".to_pid(PID::DOI).to_doi();
101    /// assert_eq!(doi.suffix(), "5678");
102    /// ```
103    fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal;
104    /// Determines if `self` is of the given PID type.
105    /// ```ignore
106    /// use acorn::schema::pid::{PID, PersistentIdentifier};
107    ///
108    /// assert!("https://doi.org/10.1234/5678".is_pid(PID::DOI));
109    /// ```
110    fn is_pid(&self, _pid_type: PID) -> bool;
111    /// Determines if `self` is an archival resource key (ARK)
112    /// ```ignore
113    /// use acorn::schema::pid::{PID, PersistentIdentifier};
114    ///
115    /// assert!("https://n2t.net/ark:12148/btv1b8449691v/f29".is_ark());
116    /// ```
117    fn is_ark(&self) -> bool;
118    /// Determines if `self` is an arXiv identifier
119    fn is_arxiv(&self) -> bool;
120    /// Determines if `self` is a DOI
121    /// ```ingore
122    /// use acorn::schema::pid::{PID, PersistentIdentifier};
123    ///
124    /// assert!("https://doi.org/10.1234/5678".is_doi());
125    /// ```
126    fn is_doi(&self) -> bool;
127    /// Determines if `self` is an ISBN
128    fn is_isbn(&self) -> bool {
129        false
130    }
131    /// Determines if `self` is a ORCID
132    /// ```ignore
133    /// use acorn::schema::pid::{PID, PersistentIdentifier};
134    ///
135    /// assert!("https://orcid.org/0000-0000-0000-0000".is_orcid());
136    /// ```
137    fn is_orcid(&self) -> bool;
138    /// Determines if `self` is a RAID
139    /// ```ignore
140    /// use acorn::schema::pid::{PID, PersistentIdentifier};
141    ///
142    /// assert!("https://raid.org/10.83962/fb5be317".is_raid());
143    /// ```````
144    fn is_raid(&self) -> bool;
145    /// Determines if `self` is a ROR
146    /// ```ignore
147    /// use acorn::schema::pid::{PID, PersistentIdentifier};
148    ///
149    /// assert!("https://ror.org/01qz5mb56".is_ror());
150    /// ```
151    fn is_ror(&self) -> bool;
152}
153/// Trait for working with persistent identifiers (PID) as and within string values
154pub trait PersistentIdentifierParse {
155    /// Find all PID values present in a string
156    fn find_all(value: impl ToString) -> Vec<Self>
157    where
158        Self: Sized;
159    /// Parse and format a PID according to its associated canonical format
160    fn format(value: impl ToString) -> String;
161    /// Instantiate a PID from a string
162    fn from_string(value: impl ToString) -> Self
163    where
164        Self: Sized;
165    /// Determine if a string is a valid PID
166    fn is_valid(value: impl ToString) -> bool;
167}
168/// Persistent Identifier (PID) types
169///
170/// PIDs are globally unique identifiers, resolvable on the Web, and associated with a set of additional descriptive metadata (ex. [`raid::Metadata`])
171#[derive(Clone, Debug, Default, EnumIs, EnumIter, Eq, Ord, PartialEq, PartialOrd)]
172pub enum PID {
173    /// Unknown PID
174    #[default]
175    Unknown,
176    /// Archival Resource Key (ARK)
177    ///
178    /// Widely used persistent identifier, supported by the California Digital Library \[21\], in collaboration with DuraSpaceď‚…. ARKs work similarly to DOIs, but are more permissive in design.[^ark]
179    ///
180    /// [^ark]: `M. Stocker et al., "Persistent Identification of Instruments," Data Science Journal, vol. 19, p. 18, May 2020, doi: 10.5334/dsj-2020-018.`
181    ARK,
182    /// arXiv identifier
183    ///
184    /// See [`ARXIV`]
185    ARXIV,
186    /// Digital Object Identifier (DOI)
187    ///
188    /// See [`DOI`]
189    DOI,
190    /// International Standard Book Number (ISBN)
191    ///
192    /// See [`ISBN`]
193    ISBN,
194    /// Open Researcher and Contributor ID (ORCiD)
195    ///
196    /// See [`ORCID`]
197    ORCID,
198    /// Patent Number
199    Patent,
200    /// Persistent Identification of Instruments (PIDINST)
201    /// ### Citation
202    /// ```text
203    /// M. Stocker et al., "Persistent Identification of Instruments," Data Science Journal, vol. 19, p. 18, May 2020, doi: 10.5334/dsj-2020-018.
204    /// ```
205    PIDINST,
206    /// Research Activity Identifier (RAiD)
207    ///
208    /// Developed by tthe Australian Research Data Commons (ARDC), used to identify research projects and activities for access by research communities worldwide
209    ///
210    /// The ARDC and [DataCite](https://datacite.org/) have entered an agreement to use DataCite [`DOI`]s as RAiD identifiers
211    ///
212    /// See [`raid`] module
213    RAID,
214    /// Research Organization Registry (ROR)
215    ///
216    /// Global, community-led registry of open persistent identifiers for research organizations
217    ///
218    /// See <https://www.ror.org/> for more information
219    ROR,
220    /// Public HTTP or HTTPS artifact URL
221    URL,
222}
223/// Internal representation of a persistent identifier
224#[derive(Default)]
225pub struct PersistentIdentifierInternal {
226    /// Raw string content of the (possible) PID
227    value: String,
228    /// Type of PID
229    pid_type: PID,
230}
231/// A persistent identifier type paired with its value.
232#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
233#[builder(start_fn = init, on(String, into))]
234pub struct Identifier {
235    /// Persistent identifier type.
236    pub kind: PID,
237    /// Identifier value, either raw or normalized.
238    pub value: String,
239}
240/// DOI or arXiv publication identifier type.
241#[derive(Clone, Debug)]
242pub enum PublicationIdentifierType {
243    /// Digital Object Identifier
244    Doi(DOI),
245    /// arXiv identifier
246    Arxiv(ARXIV),
247    /// Unsupported publication identifier
248    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/// Archival Resource Key (ARK)
260/// ### Notes
261/// - ARKs are the only mainstream, non-siloed, non-paywalled identifiers that you can register to use in about 48 hours
262/// - ARKs are decentralized
263/// - There are no fees for ARKs, PURLs, and URNs
264/// - ARKs give access to almost any kind of thing, whether digital, physical, abstract, person, group, etc.
265/// - ARKs can be deleted
266/// - ARKs support early object development
267/// - ARKs that differ only by hyphens are considered identical
268///
269/// See the [ARK specification](https://datatracker.ietf.org/doc/draft-kunze-ark/) and <https://wiki.lyrasis.org/display/ARKs/ARK+Identifiers+FAQ> for more information
270#[derive(Builder, Clone, Debug)]
271#[builder(start_fn = init, on(String, into))]
272pub struct ARK {
273    /// The part of the ARK string that your organization is responsible for making unique.
274    ///
275    /// The first 2 or more characters constitue the shoulder of the ARK which must meet the following criteria:
276    /// - Must start with one or more lowercase letters
277    /// - Must end with a digit (non-zero preferred)
278    /// - Must not contain vowels or the letter "l" (ell)
279    /// - Must not contain any `/` characters (being opaque is part of the shoulder design)
280    pub assigned_name: Option<String>,
281    /// Prefix for NAAN (e.g., "ark:" or the older, "ark:/")
282    ///
283    /// <div class="warning">Label is mandatory</div>
284    #[builder(default = "ark:".to_string())]
285    pub label: String,
286    /// Number (here represented as a string) identifying an organization that creates or assigns identifiers
287    /// ### Notes
288    /// - Since 2001, every assigned name assigning authority number (NAAN) has consisted of exactly five digits, specifically five beta-numeric digits
289    /// - Any given identifier will have exactly one NAAN but may have more than one NMA (at a time or over time)
290    /// - Similar to registration authority or prefix for [`DOI`]s, naming authority for [Handles], and namespace identifier for [URNs]
291    ///
292    /// [Handles]: https://handle.net/
293    /// [URNs]: https://en.wikipedia.org/wiki/Uniform_Resource_Name
294    pub name_assigning_authority_number: Option<String>,
295    /// String identifying a service that accepts names and returns information about them
296    /// ### Notes
297    /// - Any given identifier will have exactly one NAAN but may have more than one NMA (at a time or over time)
298    /// - Strictly speaking, NMA does not include the protocol (e.g., https), but since this implementation only supports HTTPS, we conflate what would be called the "resolver service" with NMA.
299    pub name_mapping_authority: Option<String>,
300    /// First section of optional "qualifier" part of ARK
301    ///
302    /// Generally serve as sub-namespaces to enabling grouping ARKs
303    #[builder(default = Vec::new())]
304    pub parts: Vec<String>,
305    /// Last section of optional "qualifier" part of ARK
306    ///
307    /// Typically is used to identify a specific version of a resource (i.e., "pdf", "fr", "v3", etc.)
308    #[builder(default = Vec::new())]
309    pub variants: Vec<String>,
310}
311/// Digital Object Identifier (DOI)
312///
313/// DOIs consist of a DOI name which is resolved at <https://doi.org>, with the full URI formulated according to the pattern `https://doi.org/{DOI_name}`. DOI names in turn consist of a prefix and a suffix, separated by a forward slash. The prefix is a code indicating the registrant who issues the DOI, e.g., Harvard University Dataverse - 10.7910; Dryad Digital Repository - 10.5061. The suffix is the identifier, in any form, assigned by the registrant.[^doi]
314///
315/// See <https://www.doi.org/doi-handbook/HTML/index.html> for more information
316///
317/// [^doi]: `N. Juty, S. M. Wimalaratne, S. Soiland-Reyes, J. Kunze, C. A. Goble, and T. Clark, "Unique, Persistent, Resolvable: Identifiers as the Foundation of FAIR," Data Intellegence, vol. 2, no. 1-2, pp. 30-39, Jan. 2020, doi: 10.1162/dint_a_00025.`
318#[derive(Builder, Clone, Debug)]
319#[builder(start_fn = init, on(String, into))]
320pub struct DOI {
321    /// Schema URI (i.e., <https://doi.org/>)
322    pub schema_uri: Option<String>,
323    /// Directory indicator
324    /// ### Rules
325    /// - Can contain only numeric values
326    /// - Usually 10 but other indicators may be designated as compliant by the DOI Foundation
327    pub directory_indicator: Option<String>,
328    /// Registrant code
329    /// ### Rules
330    /// - Can contain only numeric values and one or several full stops which are used to subdivide the code
331    /// - If the directory indicator is 10 then a registrant code is mandatory
332    pub registrant_code: Option<String>,
333    /// Suffix
334    /// ### Rules
335    /// - Shall be unique to the prefix element that precedes it
336    /// - Can be a sequential number
337    /// - Can be an identifier generated from or based on another system used by the registrant
338    /// - No length limit is set to the suffix by the DOI System
339    pub suffix: Option<String>,
340}
341/// arXiv identifier for an e-print
342///
343/// See <https://info.arxiv.org/help/arxiv_identifier.html> for more information
344#[derive(Builder, Clone, Debug)]
345#[builder(start_fn = init, on(String, into))]
346pub struct ARXIV {
347    /// arXiv resolver URI
348    pub schema_uri: Option<String>,
349    /// Legacy archive component, such as `hep-th`
350    pub archive: Option<String>,
351    /// Modern numeric identifier or legacy seven-digit identifier
352    pub identifier: Option<String>,
353    /// Optional revision such as `v2`
354    pub version: Option<String>,
355}
356/// International Standard Book Number (ISBN)
357///
358/// A 13-digit identification number and system, widely used in the international book trade for over 35 years and assigned through a network of [international ISBN Registration Agencies](https://www.isbn-international.org/).
359/// ISBNs are used to identify each unique publication whether in the form of a physical book or related materials such as eBooks, software, mixed media etc.
360/// ### Notes
361/// - ISBNs are governed by the ISO 2108 standard.
362/// - ISNBs can be expressed as [`DOI`]s (see [DOI system and the ISBN system](https://www.doi.org/the-identifier/resources/factsheets/doi-system-and-the-isbn-system)).
363#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
364#[builder(start_fn = init, on(String, into))]
365pub struct ISBN {
366    /// Prefix element
367    ///
368    /// ISBN (GS1) Bookland prefix = `978.` or `979.`
369    pub prefix_element: Option<String>,
370    /// Registration group element
371    ///
372    /// 1-to-5-digit number that is valid within a single prefix element
373    pub registration_group: Option<String>,
374    /// Publication prefix element
375    pub publisher: Option<String>,
376    /// ISBN Title enumerator
377    pub title: Option<String>,
378    /// Check digit
379    ///
380    /// See [`isbn_check_digit`]
381    pub check_digit: Option<String>,
382}
383/// Open Researcher and Contributor ID (ORCiD)[^orcid]
384///
385/// Disambiguates researchers, and connects people with their research activities. This includes employment affiliations, research outputs, funding, peer review activity, research resources, society membership, distinctions and other scholarly infrastructure.
386///
387/// See <https://orcid.org/> for more information
388///
389/// [^orcid]: `L. L. Haak, M. Fenner, L. Paglione, E. Pentz, and H. Ratner, "ORCID: a system to uniquely identify researchers," Learned Publishing, vol. 25, no. 4, pp. 259-264, 2012, doi: 10.1087/20120404.`
390#[derive(Builder, Clone, Debug)]
391#[builder(start_fn = init, on(String, into))]
392pub struct ORCID {
393    /// Schema URI (i.e., <https://orcid.org/>)
394    pub schema_uri: Option<String>,
395    /// 16 digit string with hyphens every 4 digits (for readability)
396    /// <div class="warning">This value can be stored with or without hyphens. To ensure compliancy, use <code>ORCID::identifier</code> method to access ORCiD identifier.</div>
397    pub identifier: Option<String>,
398    /// The check digit is the last (16th) digit of the identifier
399    /// ### Note
400    /// Check digit should be verified IAW [ISO 7064, MOD 11-2](https://www.iso.org/standard/31531.html) (see [`iso7064_check_digit`])
401    pub check_digit: Option<String>,
402}
403/// Research Activity Identifier (RAiD)[^raid]
404///
405/// RAiDs are expressed in the form of `https://raid.org/prefix/suffix`, resolvable through the RAiD portal operated by the ARDC[^ardc] at <https://raid.org/> —though they may still be resolved through any DOI or handle resolver.
406///
407/// RAiDs are governed by [ISO 23527](https://www.iso.org/standard/75931.html)
408///
409/// [^ardc]: [Australian Research Data Commons](https://ardc.edu.au/)
410#[derive(Builder, Clone, Debug)]
411#[builder(start_fn = init, on(String, into))]
412pub struct RAID {
413    /// Schema URI (e.g., <https://www.raid.org/>)
414    pub schema_uri: Option<String>,
415    /// RAiD prefix value
416    pub prefix: Option<String>,
417    /// RAiD suffix value
418    pub suffix: Option<String>,
419    /// RAiD metadata
420    ///
421    /// Metadata associated with identifier. See <https://metadata.raid.org> for more information.
422    pub metadata: Option<raid::Metadata>,
423}
424/// Research Organization Registry (ROR)[^ror]
425///
426/// A global, community-led registry of open persistent identifiers for research and funding organizations
427///
428/// [^ror]: https://ror.org/
429#[derive(Builder, Clone, Debug)]
430#[builder(start_fn = init, on(String, into))]
431pub struct ROR {
432    /// Schema URI (e.g., <https://ror.org/>)
433    pub schema_uri: Option<String>,
434    /// ROR identifier value
435    pub identifier: Option<String>,
436    /// The last two integers are a zero-padded checksum, 01 -98
437    /// ### Note
438    /// Check digits should be verified IAW [ISO 7064](https://www.iso.org/standard/31531.html)
439    pub check_digit: Option<String>,
440}
441impl Identifier {
442    /// Create an identifier with an unknown type.
443    pub fn new(value: impl Into<String>) -> Self {
444        Self {
445            kind: PID::Unknown,
446            value: value.into(),
447        }
448    }
449    /// Normalize this identifier as one supported persistent or URL artifact identifier.
450    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    /// Normalize an exact project identity key.
490    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    /// Return this identifier as a normalized exact project identity key.
501    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    /// Return the deterministic identifier hash used in artifact paths
518    #[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    /// Whether this PID type participates in artifact discovery
599    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    /// Return the stable lowercase path component
611    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    /// Whether this PID type can independently identify a research project.
627    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    /// Format a ARK into a standard format of `"{NMA}{label}{NAAN}/{Assigned Name}/{Parts}{Variants}"`
703    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    /// Format a DOI into a standard format of `"{prefix}/{suffix}"`
712    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    /// Format a ISBN into a standard format
724    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    /// Format a ORCiD into a standard format of `"{schema_uri}{identifier}"`
731    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    /// Format a RAID into a standard format of `"{prefix}/{suffix}"`
750    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    /// Format a ROR into a standard format of `"{schema_uri}{identifier}"`
757    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    /// Get DOI prefix (i.e., "{directory_indicator}.{registrant_code}")
844    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    /// Get DOI suffix
858    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    /// Return the canonical arXiv work identifier without a revision suffix.
919    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    /// Used to convert to ISBN-A DOI compatible value
949    /// See <https://www.doi.org/the-identifier/resources/factsheets/doi-system-and-the-isbn-system>
950    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    /// Used to convert to ISBN-A DOI compatible value
966    /// See <https://www.doi.org/the-identifier/resources/factsheets/doi-system-and-the-isbn-system>
967    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    /// Get ORCID schema URI
1041    /// ### Notes
1042    /// - Should always be "<https://orcid.org/>"
1043    fn schema_uri(&self) -> String {
1044        self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
1045    }
1046    /// Get ORCID identifier
1047    /// ### Notes
1048    /// - Will return an empty string if no identifier is present
1049    /// - Will always return a 19 character string with a hyphen every 4 characters (i.e., "0000-0000-0000-0000")
1050    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    /// Convert a `PersistentIdentifierInternal` to an `ARK`
1172    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    /// Convert a `PersistentIdentifierInternal` to an `ARXIV`.
1180    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    /// Convert a `PersistentIdentifierInternal` to a `DOI`
1188    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    /// Convert a `PersistentIdentifierInternal` to a `ORCID`
1196    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    /// Convert a `PersistentIdentifierInternal` to a `RAID`
1204    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    /// Convert a `PersistentIdentifierInternal` to a `ROR`
1212    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    /// Find all [`ARK`] values present in a string
1222    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    /// Convenience method for easily parsing and formatting an [`ARK`] from a string value
1230    /// ### Example
1231    /// ```rust
1232    /// use acorn::schema::pid::{ARK, PersistentIdentifierParse};
1233    ///
1234    /// assert_eq!(ARK::format("ark:/1234/5678"), "ark:1234/5678");
1235    /// let expected = "https://n2t.net/ark:12148/btv1b8449691v/f29";
1236    /// assert_eq!(ARK::format(expected), expected);
1237    /// ```
1238    fn format(value: impl ToString) -> String {
1239        ARK::from_string(value.to_string()).to_string()
1240    }
1241    /// Create new [`ARK`] by parsing raw string value
1242    /// ### Example
1243    /// ```rust
1244    /// use acorn::schema::pid::{ARK, PersistentIdentifier, PersistentIdentifierParse};
1245    ///
1246    /// let ark = ARK::from_string("https://n2t.net/ark:12148/btv1b8449691v/f42");
1247    /// assert_eq!(ark.suffix(), Some("f42".to_string()));
1248    /// ```
1249    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    /// Check if value is a valid [`ARK`]
1272    /// ### Conditions
1273    /// - ARKs are preferred to be "actionable" with the inclusion of a NMA URL, but are not required to be so (NMA is optional)
1274    /// - If ARK is to contain a URL, "https" is the only allowed scheme
1275    /// - Should have only one instance of "ark:" label
1276    /// - NAAN should be an integer
1277    /// - [Assigned name](`ARK::assigned_name`) should start with a valid [shoulder](https://arks.org/about/shoulders/)
1278    /// - Last character should be valid check digit (see [`noid_check_digit`])
1279    /// ### Example
1280    /// ```rust
1281    /// use acorn::schema::pid::{ARK, PersistentIdentifierParse};
1282    ///
1283    /// assert!(ARK::is_valid("ark:99166/w66d60p2"));
1284    /// assert!(ARK::is_valid("https://n2t.net/ark:12148/btv1b8449691v/f29"));
1285    /// ```
1286    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    /// Validate modern and legacy arXiv identifier structure.
1326    ///
1327    /// See <https://info.arxiv.org/help/arxiv_identifier.html> for the identifier formats and date boundaries.
1328    fn is_valid(value: impl ToString) -> bool {
1329        let value = value.to_string();
1330        // Require the recognized identifier to consume the complete input.
1331        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        // Permit `.pdf` only on `/pdf/` resolver URLs.
1338        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                // Modern identifiers use four sequence digits through 2014 and five afterward.
1351                | (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                // Legacy archive identifiers cover July 1991 through March 2007.
1357                | (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    /// Find all [`DOI`] values present in a string
1369    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    /// Convenience method for easily parsing and formatting a [`DOI`] from a string value
1377    /// ### Example
1378    /// ```rust
1379    /// use acorn::schema::pid::{DOI, PersistentIdentifierParse};
1380    ///
1381    /// assert_eq!(DOI::format("https://doi.org/10.1000/182"), "10.1000/182");
1382    /// assert_eq!(DOI::format("10.1000/182"), "10.1000/182");
1383    /// ```
1384    fn format(value: impl ToString) -> String {
1385        DOI::from_string(value).to_string()
1386    }
1387    /// Create new [`DOI`] by parsing raw string value
1388    /// ### Example
1389    /// ```rust
1390    /// use acorn::schema::pid::{DOI, PersistentIdentifier, PersistentIdentifierParse};
1391    ///
1392    /// let doi = DOI::from_string("https://doi.org/10.1000/182");
1393    /// assert_eq!(doi.prefix(), Some("10.1000".into()));
1394    /// assert_eq!(doi.suffix(), Some("182".into()));
1395    /// ```
1396    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    /// Check if value is a valid [`DOI`]
1409    /// ### Conditions
1410    /// - Must match DOI regular expression (see [`RE_DOI_TEXT`])
1411    /// - Is valid with or without schema URI[^format]
1412    /// - `10.5555/` is not a valid DOI prefix
1413    /// ### Example
1414    /// ```rust
1415    /// use acorn::schema::pid::{DOI, PersistentIdentifierParse};
1416    ///
1417    /// assert!(DOI::is_valid("https://doi.org/10.1000/182"));
1418    /// assert!(DOI::is_valid("10.1000/182"));
1419    /// assert!(!DOI::is_valid("10.5555/182"));
1420    /// ```
1421    ///
1422    /// [^format]: Use `DOI::format(value)` to ensure value is formatted correctly
1423    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    /// Find all [`ISBN`] values present in a string
1435    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    /// Convenience method for easily parsing and formatting a [`ISBN`] from a string value
1443    fn format(value: impl ToString) -> String {
1444        ISBN::from_string(value).to_string()
1445    }
1446    /// Create new [`ISBN`] by parsing raw string value
1447    /// ### Example
1448    /// ```rust
1449    /// use acorn::schema::pid::{ISBN, PersistentIdentifierParse};
1450    ///
1451    /// let isbn = ISBN::from_string("978-0-306-40627-0");
1452    /// assert_eq!(isbn.prefix_element, Some("978".to_string()));
1453    /// ```
1454    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    /// Check if value is a valid [`ISBN`]
1468    /// ### Conditions
1469    /// - Must be exactly 13 digits long (not including hyphens)
1470    /// - Must have a valid check digit (see [`isbn_check_digit`])
1471    /// ### Example
1472    /// ```rust
1473    /// use acorn::schema::pid::{ISBN, PersistentIdentifierParse};
1474    ///
1475    /// let isbn = ISBN::from_string("978-0-306-40627-0");
1476    /// assert!(ISBN::is_valid("978-0-306-40627-0"));
1477    /// assert!(ISBN::is_valid("9780306406270"));
1478    /// ```
1479    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    /// Find all [`ORCID`] values present in a string
1492    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    /// Convenience method for easily parsing and formatting a [`ORCID`] from a string value
1500    /// ### Example
1501    /// ```rust
1502    /// use acorn::schema::pid::{ORCID, PersistentIdentifierParse};
1503    ///
1504    /// assert_eq!(ORCID::format("https://orcid.org/0000-0002-2057-9115"), "https://orcid.org/0000-0002-2057-9115");
1505    /// assert_eq!(ORCID::format("0000-0002-2057-9115"), "https://orcid.org/0000-0002-2057-9115");
1506    /// ```
1507    fn format(value: impl ToString) -> String {
1508        ORCID::from_string(value).to_string()
1509    }
1510    /// Create new [`ORCID`] by parsing raw string value
1511    /// ### Example
1512    /// ```rust
1513    /// use acorn::schema::pid::{ORCID, PersistentIdentifier, PersistentIdentifierParse};
1514    ///
1515    /// let orcid = ORCID::from_string("https://orcid.org/0000-0002-2057-9115");
1516    /// assert_eq!(orcid.identifier(), "0000-0002-2057-9115");
1517    /// ```
1518    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    /// Check if value is a valid [`ORCiD`]
1530    /// ### Conditions
1531    /// - ORCiD identifier must be 16 characters, 0 thru 9, or "X"
1532    /// - Last character of identifier must be a valid ISO 7064 check digit (see [`orcid_check_digit`])
1533    /// - Value can be valid with or without hyphens in the ORCiD identifier[^format]
1534    /// - Value can be valid with or without schema URI[^format]
1535    /// ### Example
1536    /// ```rust
1537    /// use acorn::schema::pid::{ORCID, PersistentIdentifierParse};
1538    ///
1539    /// assert!(ORCID::is_valid("https://orcid.org/0000-0002-2057-9115"));
1540    /// assert!(ORCID::is_valid("0000-0002-2057-9115"));
1541    /// assert!(ORCID::is_valid("0000000220579115"));
1542    /// ```
1543    ///
1544    /// [^format]: Use `ORCID::format(value)` to ensure value is formatted correctly
1545    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    /// Find all [`RAID`] values present in a string
1563    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    /// Convenience method for easily parsing and formatting a [`RAID`] from a string value
1571    /// ### Example
1572    /// ```rust
1573    /// use acorn::schema::pid::{RAID, PersistentIdentifierParse};
1574    ///
1575    /// assert_eq!(RAID::format("https://raid.org/10.83962/fb5be317"), "10.83962/fb5be317");
1576    /// ```
1577    fn format(value: impl ToString) -> String {
1578        RAID::from_string(value).to_string()
1579    }
1580    /// Create new [`RAID`] by parsing a raw string value
1581    /// ### Note
1582    /// > RAiD identifiers are [`DOI`] identifiers. See this [blog post by DataCite](https://datacite.org/blog/datacite-ardc-announce-partnership-to-deliver-the-raid-service/) for details.
1583    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    /// Check if value is a valid [`RAID`]
1602    /// > See [`DOI::is_valid`] for conditions, as RAiD identifiers are [`DOI`]s
1603    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    /// Find all [`ROR`] values present in a string
1615    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    /// Convenience method for easily parsing and formatting a [`ROR`] from a string value
1624    /// ### Example
1625    /// ```rust
1626    /// use acorn::schema::pid::{ROR, PersistentIdentifierParse};
1627    ///
1628    /// assert_eq!(ROR::format("https://ror.org/01qz5mb56"), "https://ror.org/01qz5mb56");
1629    /// assert_eq!(ROR::format("01qz5mb56"), "https://ror.org/01qz5mb56");
1630    /// ```
1631    fn format(value: impl ToString) -> String {
1632        ROR::from_string(value.to_string()).to_string()
1633    }
1634    /// Create new [`ROR`] by parsing raw string value
1635    /// ### Example
1636    /// ```rust
1637    /// use acorn::schema::pid::{ROR, PersistentIdentifier, PersistentIdentifierParse};
1638    ///
1639    /// let ror = ROR::from_string("https://ror.org/01qz5mb56");
1640    /// assert_eq!(ror.identifier(), "01qz5mb56");
1641    /// ```
1642    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    /// Check if value is a valid [`ROR`]
1654    /// ### Conditions
1655    /// - Exactly 9 characters long
1656    /// - Must have a valid check digits (last two characters are zero-padded checksum, 01-98) (see [`ror_check_digit`])
1657    /// - [Base32 Crockford](https://www.crockford.com/base32.html) encoded (i.e., digits 0-9 and letters A-Z except for I, L, O, and U)
1658    /// - Value can be valid with or without schema URI[^format]
1659    /// ### Example
1660    /// ```rust
1661    /// use acorn::schema::pid::{ROR, PersistentIdentifierParse};
1662    ///
1663    /// assert!(ROR::is_valid("https://ror.org/01qz5mb56"));
1664    /// assert!(ROR::is_valid("01qz5mb56"));
1665    /// ```
1666    ///
1667    /// [^format]: Use `ROR::format(value)` to ensure value is formatted correctly
1668    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/// ISBN check digit
1690/// ### Notes
1691/// - The check digit is the last (13th) digit of the identifier
1692/// - Each digit, from left to right, is alternately multiplied by 1 or 3, then those products are summed modulo 10
1693#[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/// Calculate check xdigit ("extended digit") IAW [NOID check digit algorithm (NCDA)](https://metacpan.org/dist/Noid/view/noid#NOID-CHECK-DIGIT-ALGORITHM)
1710/// ### Notes
1711/// - Check digits are not expected to cover qualifiers
1712/// - If check digit is present in an ARK, by convention it is the right-most character of the so called "check zone"
1713/// - The "check zone" is composed of the NAAN and assigned name, separated by a forward slash
1714/// - Forward slashes do not contribute to the check digit sum, but do impact the character position index
1715/// - NCDA is guaranteed against single-character errors
1716/// - NCDA is guaranteed against transposition of two single characters
1717/// ### References
1718/// - <https://github.com/internetarchive/arklet>
1719/// - <https://github.com/no-reply/pynoid>
1720#[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/// Calculate check digit IAW [ISO 7064, MOD 11-2](https://www.iso.org/standard/31531.html)
1735///
1736/// "MOD 11-2" means modulus = 11 and radix = 2
1737///
1738/// ### Example
1739/// ```rust
1740/// use acorn::schema::pid::orcid_check_digit;
1741///
1742/// assert_eq!(orcid_check_digit("0000000220579115"), Some(vec!['5']));
1743/// assert_eq!(orcid_check_digit("0000-0002-2057-9115"), Some(vec!['5']));
1744/// ```
1745#[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/// Calculate check digit IAW [ISO 7064, MOD 97-10](https://www.iso.org/standard/31531.html)
1766///
1767/// "MOD 97-10" means modulus = 97 and radix = 10
1768///
1769/// ### Example
1770/// ```rust
1771/// use acorn::schema::pid::ror_check_digit;
1772///
1773/// assert_eq!(ror_check_digit("1qz5mb"), Some(vec!['5', '6']));
1774/// ```
1775/// ### References
1776/// - [ROR community Python implementation](https://github.com/ror-community/ror-api/blob/bd040a0d2558a478c06a89118a29eeb9b6142710/rorapi/management/commands/generaterorid.py)
1777/// - [DataCite Ruby implementation](https://github.com/datacite/base32-url/blob/master/lib/base32/url.rb)
1778#[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;