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::{DEFAULT_ORCID_SCHEMA_URI, DEFAULT_ROR_SCHEMA_URI};
13use crate::util::constants::{
14    RE_ARK, RE_ARK_TEXT, RE_DOI, RE_DOI_TEXT, RE_ISBN, RE_ISBN_TEXT, RE_ORCID, RE_ORCID_TEXT, RE_RAID_TEXT, RE_ROR, RE_ROR_TEXT,
15};
16use crate::util::{base32_crockford_decode, regex_capture_lookup, ToStringChunks};
17use bon::Builder;
18use core::fmt;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22pub mod patent;
23pub mod raid;
24
25pub use patent::Patent;
26
27const BETANUMERIC_DIGITS: &str = "0123456789bcdfghjkmnpqrstvwxz";
28
29/// Add utility functions for working with beta numeric values
30///
31/// Mostly intended for working with [NCDA](`noid_check_digit`)
32pub trait Betanumeric {
33    /// Check if `self` is a betanumeric value
34    fn is_betanumeric(&self) -> bool {
35        false
36    }
37    /// Convert `self` into a betanumeric ordinal value
38    /// ### Example
39    /// > `w` -> `26`
40    fn to_betanumeric_ordinal(&self) -> Option<usize>;
41}
42/// Provides common functions for working with persistent identifiers (PID)
43pub trait PersistentIdentifier: fmt::Display {
44    /// Create a new PID
45    fn new() -> Self;
46    /// Get standardized form of schema URI for a PID
47    /// ### Examples
48    /// - `https://doi.org`
49    /// - `https://orcid.org`
50    fn schema_uri(&self) -> String;
51    /// Get PID identifier section
52    /// ### Examples
53    /// - `ark:1234/x5678` for [`ARK`]
54    /// - `10.1234/5678` for [`DOI`]
55    /// - `0000-0002-2057-9115` for [`ORCID`]
56    fn identifier(&self) -> String;
57    /// Get PID prefix (different interpretation depending on PID type)
58    ///
59    /// Not every PID type has a prefix, but generally every PID has a "first" part that can losely be considered a "prefix"
60    fn prefix(&self) -> Option<String> {
61        None
62    }
63    /// Get PID suffix (different interpretation depending on PID type)
64    ///
65    /// Not every PID type has a suffix, but generally every PID has a "second" part that can losely be considered a "suffix"
66    fn suffix(&self) -> Option<String>;
67    /// Get PID check digit (when applicable)
68    fn check_digit(&self) -> Option<Vec<char>> {
69        None
70    }
71    /// Get fully resolved URL of the PID with its schema URI
72    fn url(&self) -> String {
73        String::new()
74    }
75}
76/// Add coercion to persistent identifier (PID) functionality to string values
77pub trait PersistentIdentifierConvert<T: AsRef<str>> {
78    /// Convert `self` into a string standard format PID of a certain type
79    /// ```ignore
80    /// use acorn::schema::pid::{PID, PersistentIdentifier};
81    ///
82    /// assert_eq!("https://doi.org/10.1234/5678".format_as(PID::DOI), "10.1234/5678");
83    /// assert_eq!("0000-0002-2057-9115".format_as(PID::ORCID), "https://orcid.org/0000-0002-2057-9115");
84    /// ```
85    fn format_as(&self, pid_type: PID) -> String;
86    /// Coerce `self` into given PID type.
87    /// ```ignore
88    /// use acorn::schema::pid::{PID, PersistentIdentifier};
89    ///
90    /// let doi = "https://doi.org/10.1234/5678".to_pid(PID::DOI).to_doi();
91    /// assert_eq!(doi.suffix(), "5678");
92    /// ```
93    fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal;
94    /// Determines if `self` is of the given PID type.
95    /// ```ignore
96    /// use acorn::schema::pid::{PID, PersistentIdentifier};
97    ///
98    /// assert!("https://doi.org/10.1234/5678".is_pid(PID::DOI));
99    /// ```
100    fn is_pid(&self, _pid_type: PID) -> bool;
101    /// Determines if `self` is an archival resource key (ARK)
102    /// ```ignore
103    /// use acorn::schema::pid::{PID, PersistentIdentifier};
104    ///
105    /// assert!("https://n2t.net/ark:12148/btv1b8449691v/f29".is_ark());
106    /// ```
107    fn is_ark(&self) -> bool;
108    /// Determines if `self` is a DOI
109    /// ```ingore
110    /// use acorn::schema::pid::{PID, PersistentIdentifier};
111    ///
112    /// assert!("https://doi.org/10.1234/5678".is_doi());
113    /// ```
114    fn is_doi(&self) -> bool;
115    /// Determines if `self` is an ISBN
116    fn is_isbn(&self) -> bool {
117        false
118    }
119    /// Determines if `self` is a ORCID
120    /// ```ignore
121    /// use acorn::schema::pid::{PID, PersistentIdentifier};
122    ///
123    /// assert!("https://orcid.org/0000-0000-0000-0000".is_orcid());
124    /// ```
125    fn is_orcid(&self) -> bool;
126    /// Determines if `self` is a RAID
127    /// ```ignore
128    /// use acorn::schema::pid::{PID, PersistentIdentifier};
129    ///
130    /// assert!("https://raid.org/10.83962/fb5be317".is_raid());
131    fn is_raid(&self) -> bool;
132    /// Determines if `self` is a ROR
133    /// ```ignore
134    /// use acorn::schema::pid::{PID, PersistentIdentifier};
135    ///
136    /// assert!("https://ror.org/01qz5mb56".is_ror());
137    fn is_ror(&self) -> bool;
138}
139/// Trait for working with persistent identifiers (PID) as and within string values
140pub trait PersistentIdentifierParse {
141    /// Find all PID values present in a string
142    fn find_all(value: impl ToString) -> Vec<Self>
143    where
144        Self: Sized;
145    /// Parse and format a PID according to its associated canonical format
146    fn format(value: impl ToString) -> String;
147    /// Instantiate a PID from a string
148    fn from_string(value: impl ToString) -> Self
149    where
150        Self: Sized;
151    /// Determine if a string is a valid PID
152    fn is_valid(value: impl ToString) -> bool;
153}
154/// Internal representation of a persistent identifier
155#[derive(Default)]
156pub struct PersistentIdentifierInternal {
157    /// Raw string content of the (possible) PID
158    value: String,
159    /// Type of PID
160    pid_type: PID,
161}
162/// Persistent Identifier (PID) types
163///
164/// PIDs are globally unique identifiers, resolvable on the Web, and associated with a set of additional descriptive metadata (ex. [`raid::Metadata`])
165#[derive(Clone, Debug, Default)]
166pub enum PID {
167    /// Unknown PID
168    #[default]
169    Unknown,
170    /// Archival Resource Key (ARK)
171    ///
172    /// 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]
173    ///
174    /// [^ark]: `M. Stocker et al., "Persistent Identification of Instruments," Data Science Journal, vol. 19, p. 18, May 2020, doi: 10.5334/dsj-2020-018.`
175    ARK,
176    /// Digital Object Identifier (DOI)
177    ///
178    /// See [`DOI`]
179    DOI,
180    /// International Standard Book Number (ISBN)
181    ///
182    /// See [`ISBN`]
183    ISBN,
184    /// Open Researcher and Contributor ID (ORCiD)
185    ///
186    /// See [`ORCID`]
187    ORCID,
188    /// Patent Number
189    Patent,
190    /// Persistent Identification of Instruments (PIDINST)
191    /// ### Citation
192    /// ```text
193    /// M. Stocker et al., "Persistent Identification of Instruments," Data Science Journal, vol. 19, p. 18, May 2020, doi: 10.5334/dsj-2020-018.
194    /// ```
195    PIDINST,
196    /// Research Activity Identifier (RAiD)
197    ///
198    /// Developed by tthe Australian Research Data Commons (ARDC), used to identify research projects and activities for access by research communities worldwide
199    ///
200    /// The ARDC and [DataCite](https://datacite.org/) have entered an agreement to use DataCite [`DOI`]s as RAiD identifiers
201    ///
202    /// See [`raid`] module
203    RAID,
204    /// Research Organization Registry (ROR)
205    ///
206    /// Global, community-led registry of open persistent identifiers for research organizations
207    ///
208    /// See <https://www.ror.org/> for more information
209    ROR,
210}
211/// Archival Resource Key (ARK)
212/// ### Notes
213/// - ARKs are the only mainstream, non-siloed, non-paywalled identifiers that you can register to use in about 48 hours
214/// - ARKs are decentralized
215/// - There are no fees for ARKs, PURLs, and URNs
216/// - ARKs give access to almost any kind of thing, whether digital, physical, abstract, person, group, etc.
217/// - ARKs can be deleted
218/// - ARKs support early object development
219/// - ARKs that differ only by hyphens are considered identical
220///
221/// 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
222#[derive(Builder, Clone, Debug)]
223#[builder(start_fn = init, on(String, into))]
224pub struct ARK {
225    /// The part of the ARK string that your organization is responsible for making unique.
226    ///
227    /// The first 2 or more characters constitue the shoulder of the ARK which must meet the following criteria:
228    /// - Must start with one or more lowercase letters
229    /// - Must end with a digit (non-zero preferred)
230    /// - Must not contain vowels or the letter "l" (ell)
231    /// - Must not contain any `/` characters (being opaque is part of the shoulder design)
232    pub assigned_name: Option<String>,
233    /// Prefix for NAAN (e.g., "ark:" or the older, "ark:/")
234    ///
235    /// <div class="warning">Label is mandatory</div>
236    #[builder(default = "ark:".to_string())]
237    pub label: String,
238    /// Number (here represented as a string) identifying an organization that creates or assigns identifiers
239    /// ### Notes
240    /// - Since 2001, every assigned name assigning authority number (NAAN) has consisted of exactly five digits, specifically five beta-numeric digits
241    /// - Any given identifier will have exactly one NAAN but may have more than one NMA (at a time or over time)
242    /// - Similar to registration authority or prefix for [`DOI`]s, naming authority for [Handles], and namespace identifier for [URNs]
243    ///
244    /// [Handles]: https://handle.net/
245    /// [URNs]: https://en.wikipedia.org/wiki/Uniform_Resource_Name
246    pub name_assigning_authority_number: Option<String>,
247    /// String identifying a service that accepts names and returns information about them
248    /// ### Notes
249    /// - Any given identifier will have exactly one NAAN but may have more than one NMA (at a time or over time)
250    /// - 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.
251    pub name_mapping_authority: Option<String>,
252    /// First section of optional "qualifier" part of ARK
253    ///
254    /// Generally serve as sub-namespaces to enabling grouping ARKs
255    #[builder(default = Vec::new())]
256    pub parts: Vec<String>,
257    /// Last section of optional "qualifier" part of ARK
258    ///
259    /// Typically is used to identify a specific version of a resource (i.e., "pdf", "fr", "v3", etc.)
260    #[builder(default = Vec::new())]
261    pub variants: Vec<String>,
262}
263/// Digital Object Identifier (DOI)
264///
265/// 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]
266///
267/// See <https://www.doi.org/doi-handbook/HTML/index.html> for more information
268///
269/// [^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.`
270#[derive(Builder, Clone, Debug)]
271#[builder(start_fn = init, on(String, into))]
272pub struct DOI {
273    /// Schema URI (i.e., <https://doi.org/>)
274    pub schema_uri: Option<String>,
275    /// Directory indicator
276    /// ### Rules
277    /// - Can contain only numeric values
278    /// - Usually 10 but other indicators may be designated as compliant by the DOI Foundation
279    pub directory_indicator: Option<String>,
280    /// Registrant code
281    /// ### Rules
282    /// - Can contain only numeric values and one or several full stops which are used to subdivide the code
283    /// - If the directory indicator is 10 then a registrant code is mandatory
284    pub registrant_code: Option<String>,
285    /// Suffix
286    /// ### Rules
287    /// - Shall be unique to the prefix element that precedes it
288    /// - Can be a sequential number
289    /// - Can be an identifier generated from or based on another system used by the registrant
290    /// - No length limit is set to the suffix by the DOI System
291    pub suffix: Option<String>,
292}
293/// International Standard Book Number (ISBN)
294///
295/// 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/).
296/// 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.
297/// ### Notes
298/// - ISBNs are governed by the ISO 2108 standard.
299/// - 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)).
300#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
301#[builder(start_fn = init, on(String, into))]
302pub struct ISBN {
303    /// Prefix element
304    ///
305    /// ISBN (GS1) Bookland prefix = `978.` or `979.`
306    pub prefix_element: Option<String>,
307    /// Registration group element
308    ///
309    /// 1-to-5-digit number that is valid within a single prefix element
310    pub registration_group: Option<String>,
311    /// Publication prefix element
312    pub publisher: Option<String>,
313    /// ISBN Title enumerator
314    pub title: Option<String>,
315    /// Check digit
316    ///
317    /// See [`isbn_check_digit`]
318    pub check_digit: Option<String>,
319}
320/// Open Researcher and Contributor ID (ORCiD)[^orcid]
321///
322/// 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.
323///
324/// See <https://orcid.org/> for more information
325///
326/// [^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.`
327#[derive(Builder, Clone, Debug)]
328#[builder(start_fn = init, on(String, into))]
329pub struct ORCID {
330    /// Schema URI (i.e., <https://orcid.org/>)
331    pub schema_uri: Option<String>,
332    /// 16 digit string with hyphens every 4 digits (for readability)
333    /// <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>
334    pub identifier: Option<String>,
335    /// The check digit is the last (16th) digit of the identifier
336    /// ### Note
337    /// Check digit should be verified IAW [ISO 7064, MOD 11-2](https://www.iso.org/standard/31531.html) (see [`iso7064_check_digit`])
338    pub check_digit: Option<String>,
339}
340/// Research Activity Identifier (RAiD)[^raid]
341///
342/// 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.
343///
344/// RAiDs are governed by [ISO 23527](https://www.iso.org/standard/75931.html)
345///
346/// [^ardc]: [Australian Research Data Commons](https://ardc.edu.au/)
347#[derive(Builder, Clone, Debug)]
348#[builder(start_fn = init, on(String, into))]
349pub struct RAID {
350    /// Schema URI (e.g., <https://www.raid.org/>)
351    pub schema_uri: Option<String>,
352    /// RAiD prefix value
353    pub prefix: Option<String>,
354    /// RAiD suffix value
355    pub suffix: Option<String>,
356    /// RAiD metadata
357    ///
358    /// Metadata associated with identifier. See <https://metadata.raid.org> for more information.
359    pub metadata: Option<raid::Metadata>,
360}
361/// Research Organization Registry (ROR)[^ror]
362///
363/// A global, community-led registry of open persistent identifiers for research and funding organizations
364///
365/// [^ror]: https://ror.org/
366#[derive(Builder, Clone, Debug)]
367#[builder(start_fn = init, on(String, into))]
368pub struct ROR {
369    /// Schema URI (e.g., <https://ror.org/>)
370    pub schema_uri: Option<String>,
371    /// ROR identifier value
372    pub identifier: Option<String>,
373    /// The last two integers are a zero-padded checksum, 01 -98
374    /// ### Note
375    /// Check digits should be verified IAW [ISO 7064](https://www.iso.org/standard/31531.html)
376    pub check_digit: Option<String>,
377}
378impl Betanumeric for char {
379    fn is_betanumeric(&self) -> bool {
380        BETANUMERIC_DIGITS.contains(*self)
381    }
382    fn to_betanumeric_ordinal(&self) -> Option<usize> {
383        BETANUMERIC_DIGITS.chars().position(|x| x.eq(self))
384    }
385}
386impl Default for ARK {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391impl Default for DOI {
392    fn default() -> Self {
393        Self::new()
394    }
395}
396impl Default for ORCID {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401impl Default for RAID {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406impl Default for ROR {
407    fn default() -> Self {
408        Self::new()
409    }
410}
411impl fmt::Display for ARK {
412    /// Format a ARK into a standard format of `"{NMA}{label}{NAAN}/{Assigned Name}/{Parts}{Variants}"`
413    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414        let nma = self.name_mapping_authority.clone().unwrap_or_default().trim_end_matches('/').to_string();
415        let identifier = self.identifier();
416        let result = [nma, identifier].into_iter().filter(|x| !x.is_empty()).collect::<Vec<String>>().join("/");
417        write!(f, "{result}")
418    }
419}
420impl fmt::Display for DOI {
421    /// Format a DOI into a standard format of `"{prefix}/{suffix}"`
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        let result = self.identifier();
424        write!(f, "{result}")
425    }
426}
427impl fmt::Display for ISBN {
428    /// Format a ISBN into a standard format
429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        let result = self.identifier();
431        write!(f, "{result}")
432    }
433}
434impl fmt::Display for ORCID {
435    /// Format a ORCiD into a standard format of `"{schema_uri}{identifier}"`
436    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
437        let schema_uri = self.schema_uri();
438        let identifier = self.identifier();
439        let uri = if schema_uri.is_empty() { DEFAULT_ORCID_SCHEMA_URI } else { &schema_uri };
440        let values = match &self.identifier {
441            | Some(_) => [uri, &identifier].to_vec(),
442            | None => vec![],
443        };
444        let result = values
445            .into_iter()
446            .filter(|x| !x.is_empty())
447            .map(String::from)
448            .collect::<Vec<String>>()
449            .join("/");
450        write!(f, "{result}")
451    }
452}
453impl fmt::Display for RAID {
454    /// Format a RAID into a standard format of `"{prefix}/{suffix}"`
455    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456        let result = self.identifier();
457        write!(f, "{result}")
458    }
459}
460impl fmt::Display for ROR {
461    /// Format a ROR into a standard format of `"{schema_uri}{identifier}"`
462    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
463        let schema_uri = self.schema_uri();
464        let result = self.identifier();
465        if result.is_empty() {
466            write!(f, "")
467        } else {
468            write!(f, "{schema_uri}{result}")
469        }
470    }
471}
472impl PersistentIdentifier for ARK {
473    fn new() -> Self {
474        ARK::init().build()
475    }
476    fn schema_uri(&self) -> String {
477        let uri = match &self.name_mapping_authority {
478            | Some(value) => value,
479            | None => "",
480        };
481        uri.trim_end_matches("/").to_string()
482    }
483    fn identifier(&self) -> String {
484        let values = [self.prefix(), self.suffix()];
485        values
486            .iter()
487            .flatten()
488            .filter(|x| !x.is_empty())
489            .map(String::from)
490            .collect::<Vec<String>>()
491            .join("/")
492    }
493    fn prefix(&self) -> Option<String> {
494        match (self.name_assigning_authority_number.as_ref(), self.assigned_name.as_ref()) {
495            | (Some(naan), Some(name)) => Some(format!("{}{}/{}", self.label.trim_end_matches('/'), naan, name)),
496            | _ => None,
497        }
498    }
499    fn suffix(&self) -> Option<String> {
500        let parts = self.parts.join("/");
501        let variants = self.variants.join(".");
502        let qualifiers = [parts, variants];
503        let result = qualifiers
504            .iter()
505            .filter(|x| !x.is_empty())
506            .map(String::from)
507            .collect::<Vec<String>>()
508            .join(".");
509        Some(result)
510    }
511    fn check_digit(&self) -> Option<Vec<char>> {
512        let Self {
513            name_assigning_authority_number: naan,
514            assigned_name: name,
515            ..
516        } = self;
517        let values = [naan.clone(), name.clone()];
518        if values.iter().all(|x| x.is_some()) {
519            let value = values.iter().flatten().map(String::from).collect::<Vec<String>>().join("/");
520            if value.is_empty() {
521                None
522            } else {
523                let trimmed = value.get(..value.len().saturating_sub(1)).unwrap_or_default();
524                noid_check_digit(trimmed)
525            }
526        } else {
527            None
528        }
529    }
530}
531impl PersistentIdentifier for DOI {
532    fn new() -> Self {
533        DOI::init().build()
534    }
535    fn schema_uri(&self) -> String {
536        self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
537    }
538    fn identifier(&self) -> String {
539        let values = [self.prefix(), self.suffix()];
540        values
541            .iter()
542            .flatten()
543            .filter(|x| !x.is_empty())
544            .map(String::from)
545            .collect::<Vec<String>>()
546            .join("/")
547    }
548    /// Get DOI prefix (i.e., "{directory_indicator}.{registrant_code}")
549    fn prefix(&self) -> Option<String> {
550        let values = [
551            self.directory_indicator.as_ref().cloned().unwrap_or_default(),
552            self.registrant_code.as_ref().cloned().unwrap_or_default(),
553        ];
554        let result = values
555            .iter()
556            .filter(|x| !x.is_empty())
557            .map(String::from)
558            .collect::<Vec<String>>()
559            .join(".");
560        Some(result)
561    }
562    /// Get DOI suffix
563    fn suffix(&self) -> Option<String> {
564        fn postprocess(mut value: String) -> String {
565            if value.ends_with(".") {
566                value.pop();
567            }
568            value
569        }
570        let result = self.suffix.as_ref().cloned().unwrap_or_default();
571        if !result.is_empty() {
572            Some(postprocess(result))
573        } else {
574            None
575        }
576    }
577    fn url(&self) -> String {
578        let identifier = self.identifier();
579        if identifier.is_empty() {
580            String::new()
581        } else {
582            let uri = self.schema_uri();
583            let schema = if uri.is_empty() { "https://doi.org" } else { &uri };
584            format!("{}/{}", schema, identifier)
585        }
586    }
587}
588impl PersistentIdentifier for ISBN {
589    fn new() -> Self {
590        ISBN::init().build()
591    }
592    fn schema_uri(&self) -> String {
593        "".to_string()
594    }
595    fn identifier(&self) -> String {
596        let ISBN {
597            prefix_element,
598            registration_group,
599            publisher,
600            title,
601            check_digit,
602        } = self;
603        [prefix_element, registration_group, publisher, title, check_digit]
604            .into_iter()
605            .map(|x| x.clone().unwrap_or_default())
606            .collect::<Vec<String>>()
607            .join("-")
608    }
609    /// Used to convert to ISBN-A DOI compatible value
610    /// See <https://www.doi.org/the-identifier/resources/factsheets/doi-system-and-the-isbn-system>
611    fn prefix(&self) -> Option<String> {
612        let ISBN {
613            prefix_element,
614            registration_group,
615            publisher,
616            ..
617        } = self;
618        let result = format!(
619            "{}.{}{}",
620            prefix_element.clone().unwrap_or_default(),
621            registration_group.clone().unwrap_or_default(),
622            publisher.clone().unwrap_or_default()
623        );
624        Some(result)
625    }
626    /// Used to convert to ISBN-A DOI compatible value
627    /// See <https://www.doi.org/the-identifier/resources/factsheets/doi-system-and-the-isbn-system>
628    fn suffix(&self) -> Option<String> {
629        let ISBN { title, check_digit, .. } = self;
630        let result = [title, check_digit]
631            .into_iter()
632            .map(|x| x.clone().unwrap_or_default())
633            .collect::<Vec<String>>()
634            .join("");
635        Some(result)
636    }
637    fn check_digit(&self) -> Option<Vec<char>> {
638        isbn_check_digit(self.identifier())
639    }
640}
641impl From<ISBN> for DOI {
642    fn from(isbn: ISBN) -> Self {
643        DOI::init()
644            .schema_uri("https://doi.org/")
645            .directory_indicator("10")
646            .maybe_registrant_code(isbn.prefix())
647            .maybe_suffix(isbn.suffix())
648            .build()
649    }
650}
651impl From<DOI> for ISBN {
652    fn from(doi: DOI) -> Self {
653        let prefix = doi.prefix().unwrap_or_default().replace(".", "-");
654        let suffix = match doi.suffix() {
655            | Some(value) => {
656                let check_digit = value.chars().last().unwrap_or_default().to_string();
657                let title = value.get(..value.len().saturating_sub(1)).unwrap_or_default().to_string();
658                format!("{title}-{check_digit}")
659            }
660            | None => "".to_string(),
661        };
662        let result = format!("{}-{suffix}", prefix.trim_start_matches("10-"));
663        ISBN::from_string(result)
664    }
665}
666impl PersistentIdentifier for ORCID {
667    fn new() -> Self {
668        ORCID::init().build()
669    }
670    /// Get ORCID schema URI
671    /// ### Notes
672    /// - Should always be "<https://orcid.org/>"
673    fn schema_uri(&self) -> String {
674        self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
675    }
676    /// Get ORCID identifier
677    /// ### Notes
678    /// - Will return an empty string if no identifier is present
679    /// - Will always return a 19 character string with a hyphen every 4 characters (i.e., "0000-0000-0000-0000")
680    fn identifier(&self) -> String {
681        let stripped = self.identifier.as_ref().cloned().unwrap_or_default().replace("-", "");
682        stripped.chunk(4).join("-")
683    }
684    fn suffix(&self) -> Option<String> {
685        Some(self.identifier())
686    }
687    fn check_digit(&self) -> Option<Vec<char>> {
688        orcid_check_digit(self.identifier())
689    }
690}
691impl PersistentIdentifier for RAID {
692    fn new() -> Self {
693        RAID::init().build()
694    }
695    fn schema_uri(&self) -> String {
696        self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
697    }
698    fn prefix(&self) -> Option<String> {
699        self.prefix.clone()
700    }
701    fn suffix(&self) -> Option<String> {
702        self.suffix.clone()
703    }
704    fn identifier(&self) -> String {
705        let values = [self.prefix(), self.suffix()];
706        values
707            .iter()
708            .flatten()
709            .filter(|x| !x.is_empty())
710            .map(String::from)
711            .collect::<Vec<String>>()
712            .join("/")
713    }
714}
715impl PersistentIdentifier for ROR {
716    fn new() -> Self {
717        ROR::init().build()
718    }
719    fn schema_uri(&self) -> String {
720        let processed = self
721            .schema_uri
722            .as_ref()
723            .cloned()
724            .unwrap_or_else(|| DEFAULT_ROR_SCHEMA_URI.to_string())
725            .trim_end_matches("/")
726            .replace(" ", "")
727            .to_string();
728        format!("{processed}/")
729    }
730    fn identifier(&self) -> String {
731        self.identifier.clone().unwrap_or_default()
732    }
733    fn suffix(&self) -> Option<String> {
734        self.identifier.clone()
735    }
736    fn check_digit(&self) -> Option<Vec<char>> {
737        ror_check_digit(&self.identifier()[1..])
738    }
739}
740impl<T: AsRef<str>> PersistentIdentifierConvert<T> for T
741where
742    T: ToString,
743{
744    fn format_as(&self, pid_type: PID) -> String {
745        match pid_type {
746            | PID::ARK => ARK::format(self.as_ref()),
747            | PID::DOI => DOI::format(self.as_ref()),
748            | PID::ORCID => ORCID::format(self.as_ref()),
749            | PID::RAID => RAID::format(self.as_ref()),
750            | PID::ROR => <ROR as PersistentIdentifierParse>::format(self.as_ref()),
751            | _ => self.as_ref().to_string(),
752        }
753    }
754    fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal {
755        let value = self.as_ref().to_string();
756        match pid_type {
757            | PID::ARK => PersistentIdentifierInternal { value, pid_type: PID::ARK },
758            | PID::DOI => PersistentIdentifierInternal { value, pid_type: PID::DOI },
759            | PID::ORCID => PersistentIdentifierInternal { value, pid_type: PID::ORCID },
760            | PID::RAID => PersistentIdentifierInternal { value, pid_type: PID::RAID },
761            | PID::ROR => PersistentIdentifierInternal { value, pid_type: PID::ROR },
762            | _ => PersistentIdentifierInternal::default(),
763        }
764    }
765    fn is_pid(&self, pid_type: PID) -> bool {
766        match pid_type {
767            | PID::ARK => self.is_ark(),
768            | PID::DOI => self.is_doi(),
769            | PID::ORCID => self.is_orcid(),
770            | PID::RAID => self.is_raid(),
771            | PID::ROR => self.is_ror(),
772            | _ => false,
773        }
774    }
775    fn is_ark(&self) -> bool {
776        ARK::is_valid(self.as_ref())
777    }
778    fn is_doi(&self) -> bool {
779        DOI::is_valid(self.as_ref())
780    }
781    fn is_isbn(&self) -> bool {
782        ISBN::is_valid(self.as_ref())
783    }
784    fn is_orcid(&self) -> bool {
785        ORCID::is_valid(self.as_ref())
786    }
787    fn is_raid(&self) -> bool {
788        RAID::is_valid(self.as_ref())
789    }
790    fn is_ror(&self) -> bool {
791        ROR::is_valid(self.as_ref())
792    }
793}
794impl PersistentIdentifierInternal {
795    /// Convert a `PersistentIdentifierInternal` to an `ARK`
796    pub fn to_ark(&self) -> ARK {
797        let PersistentIdentifierInternal { value, pid_type } = self;
798        match pid_type {
799            | PID::ARK => ARK::from_string(value),
800            | _ => ARK::default(),
801        }
802    }
803    /// Convert a `PersistentIdentifierInternal` to a `DOI`
804    pub fn to_doi(&self) -> DOI {
805        let PersistentIdentifierInternal { value, pid_type } = self;
806        match pid_type {
807            | PID::DOI => DOI::from_string(value),
808            | _ => DOI::default(),
809        }
810    }
811    /// Convert a `PersistentIdentifierInternal` to a `ORCID`
812    pub fn to_orcid(&self) -> ORCID {
813        let PersistentIdentifierInternal { value, pid_type } = self;
814        match pid_type {
815            | PID::ORCID => ORCID::from_string(value),
816            | _ => ORCID::default(),
817        }
818    }
819    /// Convert a `PersistentIdentifierInternal` to a `RAID`
820    pub fn to_raid(&self) -> RAID {
821        let PersistentIdentifierInternal { value, pid_type } = self;
822        match pid_type {
823            | PID::RAID => RAID::from_string(value),
824            | _ => RAID::default(),
825        }
826    }
827    /// Convert a `PersistentIdentifierInternal` to a `ROR`
828    pub fn to_ror(&self) -> ROR {
829        let PersistentIdentifierInternal { value, pid_type } = self;
830        match pid_type {
831            | PID::ROR => ROR::from_string(value),
832            | _ => ROR::default(),
833        }
834    }
835}
836impl PersistentIdentifierParse for ARK {
837    /// Find all [`ARK`] values present in a string
838    fn find_all(value: impl ToString) -> Vec<Self> {
839        let re = &RE_ARK;
840        re.find_iter(&value.to_string())
841            .filter_map(Result::ok)
842            .map(|m| ARK::from_string(m.as_str()))
843            .collect()
844    }
845    /// Convenience method for easily parsing and formatting an [`ARK`] from a string value
846    /// ### Example
847    /// ```rust
848    /// use acorn::schema::pid::{ARK, PersistentIdentifierParse};
849    ///
850    /// assert_eq!(ARK::format("ark:/1234/5678"), "ark:1234/5678");
851    /// let expected = "https://n2t.net/ark:12148/btv1b8449691v/f29";
852    /// assert_eq!(ARK::format(expected), expected);
853    /// ```
854    fn format(value: impl ToString) -> String {
855        ARK::from_string(value.to_string()).to_string()
856    }
857    /// Create new [`ARK`] by parsing raw string value
858    /// ### Example
859    /// ```rust
860    /// use acorn::schema::pid::{ARK, PersistentIdentifier, PersistentIdentifierParse};
861    ///
862    /// let ark = ARK::from_string("https://n2t.net/ark:12148/btv1b8449691v/f42");
863    /// assert_eq!(ark.suffix(), Some("f42".to_string()));
864    /// ```
865    fn from_string(value: impl ToString) -> Self {
866        let groups = ["nma", "label", "naan", "assigned_name", "parts", "variants"];
867        let pattern = format!("^{RE_ARK_TEXT}$");
868        let text = value.to_string();
869        let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
870        let parts = match lookup.get("parts") {
871            | Some(value) => value.split('/').map(String::from).collect(),
872            | None => vec![],
873        };
874        let variants = match lookup.get("variants") {
875            | Some(value) => value.split('.').map(String::from).collect(),
876            | None => vec![],
877        };
878        ARK::init()
879            .maybe_assigned_name(lookup.get("assigned_name").cloned())
880            .maybe_label(lookup.get("label").cloned())
881            .maybe_name_assigning_authority_number(lookup.get("naan").cloned())
882            .maybe_name_mapping_authority(lookup.get("nma").cloned())
883            .parts(parts)
884            .variants(variants)
885            .build()
886    }
887    /// Check if value is a valid [`ARK`]
888    /// ### Conditions
889    /// - ARKs are preferred to be "actionable" with the inclusion of a NMA URL, but are not required to be so (NMA is optional)
890    /// - If ARK is to contain a URL, "https" is the only allowed scheme
891    /// - Should have only one instance of "ark:" label
892    /// - NAAN should be an integer
893    /// - [Assigned name](`ARK::assigned_name`) should start with a valid [shoulder](https://arks.org/about/shoulders/)
894    /// - Last character should be valid check digit (see [`noid_check_digit`])
895    /// ### Example
896    /// ```rust
897    /// use acorn::schema::pid::{ARK, PersistentIdentifierParse};
898    ///
899    /// assert!(ARK::is_valid("ark:99166/w66d60p2"));
900    /// assert!(ARK::is_valid("https://n2t.net/ark:12148/btv1b8449691v/f29"));
901    /// ```
902    fn is_valid(value: impl ToString) -> bool {
903        let pid = ARK::from_string(value);
904        let naan = pid.name_assigning_authority_number.unwrap_or_default();
905        let naan_is_betanumeric = naan.chars().all(|x| x.is_betanumeric());
906        let shoulder_starts_with_lowercase_letter = match pid.assigned_name {
907            | Some(value) => match value.chars().next() {
908                | Some(value) => value.is_ascii_lowercase() && !value.eq(&'l'),
909                | None => false,
910            },
911            | None => false,
912        };
913        !naan.is_empty() && naan_is_betanumeric && shoulder_starts_with_lowercase_letter
914    }
915}
916impl PersistentIdentifierParse for DOI {
917    /// Find all [`DOI`] values present in a string
918    fn find_all(value: impl ToString) -> Vec<Self> {
919        let re = &RE_DOI;
920        re.find_iter(&value.to_string())
921            .filter_map(Result::ok)
922            .map(|m| DOI::from_string(m.as_str()))
923            .collect()
924    }
925    /// Convenience method for easily parsing and formatting a [`DOI`] from a string value
926    /// ### Example
927    /// ```rust
928    /// use acorn::schema::pid::{DOI, PersistentIdentifierParse};
929    ///
930    /// assert_eq!(DOI::format("https://doi.org/10.1000/182"), "10.1000/182");
931    /// assert_eq!(DOI::format("10.1000/182"), "10.1000/182");
932    /// ```
933    fn format(value: impl ToString) -> String {
934        DOI::from_string(value).to_string()
935    }
936    /// Create new [`DOI`] by parsing raw string value
937    /// ### Example
938    /// ```rust
939    /// use acorn::schema::pid::{DOI, PersistentIdentifier, PersistentIdentifierParse};
940    ///
941    /// let doi = DOI::from_string("https://doi.org/10.1000/182");
942    /// assert_eq!(doi.prefix(), Some("10.1000".into()));
943    /// assert_eq!(doi.suffix(), Some("182".into()));
944    /// ```
945    fn from_string(value: impl ToString) -> Self {
946        let groups = ["schema_uri", "directory_indicator", "prefix_element", "registrant_code", "suffix"];
947        let pattern = format!("^{RE_DOI_TEXT}$");
948        let text = value.to_string();
949        let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
950        DOI::init()
951            .maybe_schema_uri(lookup.get("schema_uri").cloned())
952            .maybe_directory_indicator(lookup.get("directory_indicator").cloned())
953            .maybe_registrant_code(lookup.get("registrant_code").cloned())
954            .maybe_suffix(lookup.get("suffix").cloned())
955            .build()
956    }
957    /// Check if value is a valid [`DOI`]
958    /// ### Conditions
959    /// - Must match DOI regular expression (see [`RE_DOI_TEXT`])
960    /// - Is valid with or without schema URI[^format]
961    /// - `10.5555/` is not a valid DOI prefix
962    /// ### Example
963    /// ```rust
964    /// use acorn::schema::pid::{DOI, PersistentIdentifierParse};
965    ///
966    /// assert!(DOI::is_valid("https://doi.org/10.1000/182"));
967    /// assert!(DOI::is_valid("10.1000/182"));
968    /// assert!(!DOI::is_valid("10.5555/182"));
969    /// ```
970    ///
971    /// [^format]: Use `DOI::format(value)` to ensure value is formatted correctly
972    fn is_valid(value: impl ToString) -> bool {
973        let pid = DOI::from_string(value.to_string());
974        let prefix_is_valid = match pid.prefix() {
975            | Some(x) => is_numeric(&x.replace(".", "")) && !x.eq("10.5555"),
976            | _ => false,
977        };
978        let suffix_is_valid = pid.suffix().is_some();
979        prefix_is_valid && suffix_is_valid
980    }
981}
982impl PersistentIdentifierParse for ISBN {
983    /// Find all [`ISBN`] values present in a string
984    fn find_all(value: impl ToString) -> Vec<Self> {
985        let re = &RE_ISBN;
986        re.find_iter(&value.to_string())
987            .filter_map(Result::ok)
988            .map(|m| ISBN::from_string(m.as_str()))
989            .collect()
990    }
991    /// Convenience method for easily parsing and formatting a [`ISBN`] from a string value
992    fn format(value: impl ToString) -> String {
993        ISBN::from_string(value).to_string()
994    }
995    /// Create new [`ISBN`] by parsing raw string value
996    /// ### Example
997    /// ```rust
998    /// use acorn::schema::pid::{ISBN, PersistentIdentifierParse};
999    ///
1000    /// let isbn = ISBN::from_string("978-0-306-40627-0");
1001    /// assert_eq!(isbn.prefix_element, Some("978".to_string()));
1002    /// ```
1003    fn from_string(value: impl ToString) -> Self {
1004        let groups = ["prefix_element", "registration_group", "publisher", "title", "check_digit"];
1005        let pattern = format!("^{RE_ISBN_TEXT}$");
1006        let text = value.to_string();
1007        let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1008        ISBN::init()
1009            .maybe_prefix_element(lookup.get("prefix_element").cloned())
1010            .maybe_registration_group(lookup.get("registration_group").cloned())
1011            .maybe_publisher(lookup.get("publisher").cloned())
1012            .maybe_title(lookup.get("title").cloned())
1013            .maybe_check_digit(lookup.get("check_digit").cloned())
1014            .build()
1015    }
1016    /// Check if value is a valid [`ISBN`]
1017    /// ### Conditions
1018    /// - Must be exactly 13 digits long (not including hyphens)
1019    /// - Must have a valid check digit (see [`isbn_check_digit`])
1020    /// ### Example
1021    /// ```rust
1022    /// use acorn::schema::pid::{ISBN, PersistentIdentifierParse};
1023    ///
1024    /// let isbn = ISBN::from_string("978-0-306-40627-0");
1025    /// assert!(ISBN::is_valid("978-0-306-40627-0"));
1026    /// assert!(ISBN::is_valid("9780306406270"));
1027    /// ```
1028    fn is_valid(value: impl ToString) -> bool {
1029        let pid = ISBN::from_string(value.to_string());
1030        let last = value.to_string().chars().last().unwrap_or_default();
1031        let has_valid_check_digit = match pid.check_digit() {
1032            | Some(chars) => chars.contains(&last),
1033            | _ => false,
1034        };
1035        let is_valid_length = value.to_string().replace("-", "").len() == 13;
1036        has_valid_check_digit && is_valid_length
1037    }
1038}
1039impl PersistentIdentifierParse for ORCID {
1040    /// Find all [`ORCID`] values present in a string
1041    fn find_all(value: impl ToString) -> Vec<Self> {
1042        let re = &RE_ORCID;
1043        re.find_iter(&value.to_string())
1044            .filter_map(Result::ok)
1045            .map(|m| ORCID::from_string(m.as_str()))
1046            .collect()
1047    }
1048    /// Convenience method for easily parsing and formatting a [`ORCID`] from a string value
1049    /// ### Example
1050    /// ```rust
1051    /// use acorn::schema::pid::{ORCID, PersistentIdentifierParse};
1052    ///
1053    /// assert_eq!(ORCID::format("https://orcid.org/0000-0002-2057-9115"), "https://orcid.org/0000-0002-2057-9115");
1054    /// assert_eq!(ORCID::format("0000-0002-2057-9115"), "https://orcid.org/0000-0002-2057-9115");
1055    /// ```
1056    fn format(value: impl ToString) -> String {
1057        ORCID::from_string(value).to_string()
1058    }
1059    /// Create new [`ORCID`] by parsing raw string value
1060    /// ### Example
1061    /// ```rust
1062    /// use acorn::schema::pid::{ORCID, PersistentIdentifier, PersistentIdentifierParse};
1063    ///
1064    /// let orcid = ORCID::from_string("https://orcid.org/0000-0002-2057-9115");
1065    /// assert_eq!(orcid.identifier(), "0000-0002-2057-9115");
1066    /// ```
1067    fn from_string(value: impl ToString) -> Self {
1068        let groups = ["schema_uri", "identifier", "check_digit"];
1069        let pattern = format!("^{RE_ORCID_TEXT}$");
1070        let text = value.to_string();
1071        let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1072        ORCID::init()
1073            .maybe_schema_uri(lookup.get("schema_uri").cloned())
1074            .maybe_identifier(lookup.get("identifier").cloned())
1075            .maybe_check_digit(lookup.get("check_digit").cloned())
1076            .build()
1077    }
1078    /// Check if value is a valid [`ORCiD`]
1079    /// ### Conditions
1080    /// - ORCiD identifier must be 16 characters, 0 thru 9, or "X"
1081    /// - Last character of identifier must be a valid ISO 7064 check digit (see [`orcid_check_digit`])
1082    /// - Value can be valid with or without hyphens in the ORCiD identifier[^format]
1083    /// - Value can be valid with or without schema URI[^format]
1084    /// ### Example
1085    /// ```rust
1086    /// use acorn::schema::pid::{ORCID, PersistentIdentifierParse};
1087    ///
1088    /// assert!(ORCID::is_valid("https://orcid.org/0000-0002-2057-9115"));
1089    /// assert!(ORCID::is_valid("0000-0002-2057-9115"));
1090    /// assert!(ORCID::is_valid("0000000220579115"));
1091    /// ```
1092    ///
1093    /// [^format]: Use `ORCID::format(value)` to ensure value is formatted correctly
1094    fn is_valid(value: impl ToString) -> bool {
1095        let pid = ORCID::from_string(value.to_string());
1096        let identifier = pid.identifier();
1097        let last = identifier.chars().last().unwrap_or_default();
1098        match orcid_check_digit(identifier.as_str()) {
1099            | Some(check_digit) => {
1100                if check_digit.contains(&last) {
1101                    identifier.len() == 19
1102                } else {
1103                    false
1104                }
1105            }
1106            | _ => false,
1107        }
1108    }
1109}
1110impl PersistentIdentifierParse for RAID {
1111    /// Find all [`RAID`] values present in a string
1112    fn find_all(value: impl ToString) -> Vec<Self> {
1113        let re = &RE_DOI;
1114        re.find_iter(&value.to_string())
1115            .filter_map(Result::ok)
1116            .map(|m| RAID::from_string(m.as_str()))
1117            .collect()
1118    }
1119    /// Convenience method for easily parsing and formatting a [`RAID`] from a string value
1120    /// ### Example
1121    /// ```rust
1122    /// use acorn::schema::pid::{RAID, PersistentIdentifierParse};
1123    ///
1124    /// assert_eq!(RAID::format("https://raid.org/10.83962/fb5be317"), "10.83962/fb5be317");
1125    /// ```
1126    fn format(value: impl ToString) -> String {
1127        RAID::from_string(value).to_string()
1128    }
1129    /// Create new [`RAID`] by parsing a raw string value
1130    /// ### Note
1131    /// > 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.
1132    fn from_string(value: impl ToString) -> Self {
1133        let groups = ["schema_uri", "directory_indicator", "registrant_code", "suffix"];
1134        let pattern = format!("^{RE_RAID_TEXT}$");
1135        let text = value.to_string();
1136        let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1137        let directory_indicator = lookup.get("directory_indicator").cloned();
1138        let registrant_code = lookup.get("registrant_code").cloned();
1139        let prefix = [directory_indicator, registrant_code]
1140            .into_iter()
1141            .flatten()
1142            .collect::<Vec<String>>()
1143            .join(".");
1144        RAID::init()
1145            .prefix(prefix)
1146            .maybe_schema_uri(lookup.get("schema_uri").cloned())
1147            .maybe_suffix(lookup.get("suffix").cloned())
1148            .build()
1149    }
1150    /// Check if value is a valid [`RAID`]
1151    /// > See [`DOI::is_valid`] for conditions, as RAiD identifiers are [`DOI`]s
1152    fn is_valid(value: impl ToString) -> bool {
1153        let pid = RAID::from_string(value.to_string());
1154        let prefix_is_valid = match pid.prefix() {
1155            | Some(x) => is_numeric(&x.replace(".", "")) && !x.eq("10.5555"),
1156            | _ => false,
1157        };
1158        let suffix_is_valid = pid.suffix().is_some();
1159        prefix_is_valid && suffix_is_valid
1160    }
1161}
1162impl PersistentIdentifierParse for ROR {
1163    /// Find all [`ROR`] values present in a string
1164    fn find_all(value: impl ToString) -> Vec<Self> {
1165        let re = &RE_ROR;
1166        re.find_iter(&value.to_string())
1167            .filter_map(Result::ok)
1168            .map(|m| ROR::from_string(m.as_str()))
1169            .collect()
1170    }
1171    /// Convenience method for easily parsing and formatting a [`ROR`] from a string value
1172    /// ### Example
1173    /// ```rust
1174    /// use acorn::schema::pid::{ROR, PersistentIdentifierParse};
1175    ///
1176    /// assert_eq!(ROR::format("https://ror.org/01qz5mb56"), "https://ror.org/01qz5mb56");
1177    /// assert_eq!(ROR::format("01qz5mb56"), "https://ror.org/01qz5mb56");
1178    /// ```
1179    fn format(value: impl ToString) -> String {
1180        ROR::from_string(value.to_string()).to_string()
1181    }
1182    /// Create new [`ROR`] by parsing raw string value
1183    /// ### Example
1184    /// ```rust
1185    /// use acorn::schema::pid::{ROR, PersistentIdentifier, PersistentIdentifierParse};
1186    ///
1187    /// let ror = ROR::from_string("https://ror.org/01qz5mb56");
1188    /// assert_eq!(ror.identifier(), "01qz5mb56");
1189    /// ```
1190    fn from_string(value: impl ToString) -> Self {
1191        let groups = ["schema_uri", "identifier", "check_digit"];
1192        let pattern = format!("^{RE_ROR_TEXT}$");
1193        let text = value.to_string();
1194        let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
1195        ROR::init()
1196            .maybe_schema_uri(lookup.get("schema_uri").cloned())
1197            .maybe_identifier(lookup.get("identifier").cloned())
1198            .maybe_check_digit(lookup.get("check_digit").cloned())
1199            .build()
1200    }
1201    /// Check if value is a valid [`ROR`]
1202    /// ### Conditions
1203    /// - Exactly 9 characters long
1204    /// - Must have a valid check digits (last two characters are zero-padded checksum, 01-98) (see [`ror_check_digit`])
1205    /// - [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)
1206    /// - Value can be valid with or without schema URI[^format]
1207    /// ### Example
1208    /// ```rust
1209    /// use acorn::schema::pid::{ROR, PersistentIdentifierParse};
1210    ///
1211    /// assert!(ROR::is_valid("https://ror.org/01qz5mb56"));
1212    /// assert!(ROR::is_valid("01qz5mb56"));
1213    /// ```
1214    ///
1215    /// [^format]: Use `ROR::format(value)` to ensure value is formatted correctly
1216    fn is_valid(value: impl ToString) -> bool {
1217        let pid = ROR::from_string(value.to_string());
1218        let identifier = pid.identifier();
1219        let last_two = identifier.chars().rev().take(2).collect::<String>().chars().rev().collect::<String>();
1220        if identifier.is_empty() {
1221            false
1222        } else {
1223            match ror_check_digit(&identifier[1..]) {
1224                | Some(check_digit) => {
1225                    if identifier.len() == 9 {
1226                        let calculated_last_two = check_digit.iter().collect::<String>();
1227                        calculated_last_two == last_two
1228                    } else {
1229                        false
1230                    }
1231                }
1232                | _ => false,
1233            }
1234        }
1235    }
1236}
1237/// ISBN check digit
1238/// ### Notes
1239/// - The check digit is the last (13th) digit of the identifier
1240/// - Each digit, from left to right, is alternately multiplied by 1 or 3, then those products are summed modulo 10
1241#[allow(clippy::arithmetic_side_effects)]
1242pub fn isbn_check_digit<S>(_value: S) -> Option<Vec<char>>
1243where
1244    S: AsRef<str>,
1245{
1246    const MODULUS: u32 = 10;
1247    let working = _value.as_ref().replace("-", "");
1248    let sum = working.chars().take(12).enumerate().fold(0, |acc, (index, x)| {
1249        let digit = x.to_digit(10).unwrap_or_default();
1250        let multiplier = if index % 2 == 0 { 1 } else { 3 };
1251        acc + (digit * multiplier)
1252    });
1253    let remainder = sum % MODULUS;
1254    let result = if remainder == 0 { 0 } else { MODULUS - remainder };
1255    char::from_digit(result, 10).map(|c| vec![c])
1256}
1257/// Calculate check xdigit ("extended digit") IAW [NOID check digit algorithm (NCDA)](https://metacpan.org/dist/Noid/view/noid#NOID-CHECK-DIGIT-ALGORITHM)
1258/// ### Notes
1259/// - Check digits are not expected to cover qualifiers
1260/// - If check digit is present in an ARK, by convention it is the right-most character of the so called "check zone"
1261/// - The "check zone" is composed of the NAAN and assigned name, separated by a forward slash
1262/// - Forward slashes do not contribute to the check digit sum, but do impact the character position index
1263/// - NCDA is guaranteed against single-character errors
1264/// - NCDA is guaranteed against transposition of two single characters
1265/// ### References
1266/// - <https://github.com/internetarchive/arklet>
1267/// - <https://github.com/no-reply/pynoid>
1268#[allow(clippy::arithmetic_side_effects)]
1269pub fn noid_check_digit<S>(value: S) -> Option<Vec<char>>
1270where
1271    S: AsRef<str>,
1272{
1273    const RADIX: usize = 29;
1274    let sum = value.as_ref().chars().enumerate().fold(0, |acc, (i, val)| {
1275        let position = i + 1;
1276        let ordinal = val.to_betanumeric_ordinal().unwrap_or(0);
1277        acc + (position * ordinal)
1278    });
1279    let remainder = sum % RADIX;
1280    to_betanumeric(remainder as u8).map(|c| vec![c])
1281}
1282/// Calculate check digit IAW [ISO 7064, MOD 11-2](https://www.iso.org/standard/31531.html)
1283///
1284/// "MOD 11-2" means modulus = 11 and radix = 2
1285///
1286/// ### Example
1287/// ```rust
1288/// use acorn::schema::pid::orcid_check_digit;
1289///
1290/// assert_eq!(orcid_check_digit("0000000220579115"), Some(vec!['5']));
1291/// assert_eq!(orcid_check_digit("0000-0002-2057-9115"), Some(vec!['5']));
1292/// ```
1293#[allow(clippy::arithmetic_side_effects)]
1294pub fn orcid_check_digit<S>(value: S) -> Option<Vec<char>>
1295where
1296    S: AsRef<str>,
1297{
1298    const MODULUS: u32 = 11;
1299    const RADIX: u32 = 2;
1300    let working = value.as_ref().replace("-", "").replace(" ", "");
1301    let sum = working.chars().take(15).fold(0, |acc, x| {
1302        let digit = x.to_digit(10).unwrap_or_default();
1303        (acc + digit) * RADIX
1304    });
1305    let remainder = sum % MODULUS;
1306    let result = (MODULUS + 1 - remainder) % MODULUS;
1307    if result == 10 {
1308        Some(vec!['X'])
1309    } else {
1310        char::from_digit(result, 10).map(|c| vec![c])
1311    }
1312}
1313/// Calculate check digit IAW [ISO 7064, MOD 97-10](https://www.iso.org/standard/31531.html)
1314///
1315/// "MOD 97-10" means modulus = 97 and radix = 10
1316///
1317/// ### Example
1318/// ```rust
1319/// use acorn::schema::pid::ror_check_digit;
1320///
1321/// assert_eq!(ror_check_digit("1qz5mb"), Some(vec!['5', '6']));
1322/// ```
1323/// ### References
1324/// - [ROR community Python implementation](https://github.com/ror-community/ror-api/blob/bd040a0d2558a478c06a89118a29eeb9b6142710/rorapi/management/commands/generaterorid.py)
1325/// - [DataCite Ruby implementation](https://github.com/datacite/base32-url/blob/master/lib/base32/url.rb)
1326#[allow(clippy::arithmetic_side_effects)]
1327pub fn ror_check_digit<S>(value: S) -> Option<Vec<char>>
1328where
1329    S: AsRef<str>,
1330{
1331    const MODULUS: u128 = 97;
1332    let working = value
1333        .as_ref()
1334        .replace("-", "")
1335        .replace(" ", "")
1336        .chars()
1337        .take(6)
1338        .map(String::from)
1339        .collect::<Vec<_>>()
1340        .join("");
1341    match base32_crockford_decode(working) {
1342        | Some(value) => {
1343            let remainder = (value * 100) % MODULUS;
1344            let checksum = (MODULUS + 1 - remainder) % MODULUS;
1345            let result = if checksum < 10 {
1346                format!("0{}", checksum).chars().collect()
1347            } else {
1348                checksum.to_string().chars().collect()
1349            };
1350            Some(result)
1351        }
1352        | None => None,
1353    }
1354}
1355fn to_betanumeric(value: u8) -> Option<char> {
1356    match BETANUMERIC_DIGITS.chars().enumerate().find(|(i, _)| *i == value as usize) {
1357        | Some((_, x)) => Some(x),
1358        | None => None,
1359    }
1360}
1361fn is_numeric(value: &str) -> bool {
1362    value.chars().all(|x| x.is_numeric())
1363}
1364
1365#[cfg(test)]
1366mod tests;