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;