use crate::prelude::*;
use crate::schema::namespaces::{
ARXIV_DATACITE_REGISTRANT_CODE, DATACITE_DOI_DIRECTORY_INDICATOR, DEFAULT_ARXIV_SCHEMA_URI, DEFAULT_DOI_SCHEMA_URI, DEFAULT_ORCID_SCHEMA_URI,
DEFAULT_ROR_SCHEMA_URI,
};
use crate::util::constants::{
HTTP_URL, RE_ARK, RE_ARK_TEXT, RE_ARXIV, RE_ARXIV_TEXT, RE_DOI, RE_DOI_TEXT, RE_ISBN, RE_ISBN_TEXT, RE_ORCID, RE_ORCID_TEXT, RE_RAID_TEXT,
RE_ROR, RE_ROR_TEXT,
};
use crate::util::{base32_crockford_decode, regex_capture_lookup, trim_unmatched_trailing_parentheses, ToStringChunks};
use bon::Builder;
use core::fmt;
#[cfg(feature = "std")]
use data_encoding::HEXLOWER;
#[cfg(feature = "std")]
use ring::digest::{digest, SHA256};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use strum::{EnumIs, EnumIter, IntoEnumIterator};
use validator::ValidationError;
pub mod patent;
pub mod raid;
pub use patent::Patent;
const BETANUMERIC_DIGITS: &str = "0123456789bcdfghjkmnpqrstvwxz";
pub trait Betanumeric {
fn is_betanumeric(&self) -> bool {
false
}
fn to_betanumeric_ordinal(&self) -> Option<usize>;
}
pub trait PersistentIdentifier: fmt::Display {
fn new() -> Self;
fn schema_uri(&self) -> String;
fn identifier(&self) -> String;
fn prefix(&self) -> Option<String> {
None
}
fn suffix(&self) -> Option<String>;
fn check_digit(&self) -> Option<Vec<char>> {
None
}
fn url(&self) -> String {
String::new()
}
}
pub trait PersistentIdentifierConvert<T: AsRef<str>> {
fn format_as(&self, pid_type: PID) -> String;
fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal;
fn is_pid(&self, _pid_type: PID) -> bool;
fn is_ark(&self) -> bool;
fn is_arxiv(&self) -> bool;
fn is_doi(&self) -> bool;
fn is_isbn(&self) -> bool {
false
}
fn is_orcid(&self) -> bool;
fn is_raid(&self) -> bool;
fn is_ror(&self) -> bool;
}
pub trait PersistentIdentifierParse {
fn find_all(value: impl ToString) -> Vec<Self>
where
Self: Sized;
fn format(value: impl ToString) -> String;
fn from_string(value: impl ToString) -> Self
where
Self: Sized;
fn is_valid(value: impl ToString) -> bool;
}
#[derive(Clone, Debug, Default, EnumIs, EnumIter, Eq, Ord, PartialEq, PartialOrd)]
pub enum PID {
#[default]
Unknown,
ARK,
ARXIV,
DOI,
ISBN,
ORCID,
Patent,
PIDINST,
RAID,
ROR,
URL,
}
#[derive(Default)]
pub struct PersistentIdentifierInternal {
value: String,
pid_type: PID,
}
#[derive(Builder, Clone, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[builder(start_fn = init, on(String, into))]
pub struct Identifier {
pub kind: PID,
pub value: String,
}
#[derive(Clone, Debug)]
pub enum PublicationIdentifierType {
Doi(DOI),
Arxiv(ARXIV),
Unknown,
}
impl From<&str> for PublicationIdentifierType {
fn from(value: &str) -> Self {
match (DOI::is_valid(value), ARXIV::is_valid(value)) {
| (true, _) => Self::Doi(DOI::from_string(value)),
| (_, true) => Self::Arxiv(ARXIV::from_string(value)),
| _ => Self::Unknown,
}
}
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = init, on(String, into))]
pub struct ARK {
pub assigned_name: Option<String>,
#[builder(default = "ark:".to_string())]
pub label: String,
pub name_assigning_authority_number: Option<String>,
pub name_mapping_authority: Option<String>,
#[builder(default = Vec::new())]
pub parts: Vec<String>,
#[builder(default = Vec::new())]
pub variants: Vec<String>,
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = init, on(String, into))]
pub struct DOI {
pub schema_uri: Option<String>,
pub directory_indicator: Option<String>,
pub registrant_code: Option<String>,
pub suffix: Option<String>,
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = init, on(String, into))]
pub struct ARXIV {
pub schema_uri: Option<String>,
pub archive: Option<String>,
pub identifier: Option<String>,
pub version: Option<String>,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[builder(start_fn = init, on(String, into))]
pub struct ISBN {
pub prefix_element: Option<String>,
pub registration_group: Option<String>,
pub publisher: Option<String>,
pub title: Option<String>,
pub check_digit: Option<String>,
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = init, on(String, into))]
pub struct ORCID {
pub schema_uri: Option<String>,
pub identifier: Option<String>,
pub check_digit: Option<String>,
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = init, on(String, into))]
pub struct RAID {
pub schema_uri: Option<String>,
pub prefix: Option<String>,
pub suffix: Option<String>,
pub metadata: Option<raid::Metadata>,
}
#[derive(Builder, Clone, Debug)]
#[builder(start_fn = init, on(String, into))]
pub struct ROR {
pub schema_uri: Option<String>,
pub identifier: Option<String>,
pub check_digit: Option<String>,
}
impl Identifier {
pub fn new(value: impl Into<String>) -> Self {
Self {
kind: PID::Unknown,
value: value.into(),
}
}
pub fn normalized(&self) -> Option<Self> {
let trimmed = self
.value
.trim()
.trim_matches(|character: char| matches!(character, '<' | '>' | '(' | ')' | '[' | ']' | ',' | ';'));
let trimmed = trim_unmatched_trailing_parentheses(trimmed);
match self.kind {
| PID::DOI => Self::parsed::<DOI>(PID::DOI, trimmed),
| PID::ARXIV => Self::parsed::<ARXIV>(PID::ARXIV, trimmed),
| PID::ISBN => Self::parsed::<ISBN>(PID::ISBN, trimmed),
| PID::ORCID => Self::parsed::<ORCID>(PID::ORCID, trimmed),
| PID::ARK => Self::parsed::<ARK>(PID::ARK, trimmed),
| PID::RAID => Self::parsed::<RAID>(PID::RAID, trimmed),
| PID::Patent => Self::parsed::<Patent>(PID::Patent, trimmed),
| PID::ROR => Self::parsed::<ROR>(PID::ROR, trimmed),
| PID::URL if HTTP_URL.is_match(trimmed).unwrap_or(false) => Some(Self {
kind: PID::URL,
value: trimmed.trim_end_matches('/').to_string(),
}),
| PID::Unknown => {
let lowercase = trimmed.to_ascii_lowercase();
let primary = if lowercase.starts_with("raid:") || lowercase.starts_with("https://raid.org/") {
PID::RAID
} else {
PID::DOI
};
[PID::ARXIV, primary, PID::ARK, PID::ISBN, PID::ORCID, PID::Patent, PID::ROR, PID::URL]
.into_iter()
.find_map(|kind| {
Self {
kind,
value: self.value.clone(),
}
.normalized()
})
}
| _ => None,
}
}
pub fn normalize(value: &str) -> String {
let value = value.trim();
match value.split_once(':') {
| Some((prefix, identifier)) if matches!(prefix.to_ascii_lowercase().as_str(), "arxiv" | "doi" | "raid" | "isbn" | "patent") => {
format!("{}:{}", prefix.to_ascii_lowercase(), identifier.trim().to_ascii_lowercase())
}
| Some((prefix, identifier)) => format!("{}:{}", prefix.to_ascii_lowercase(), identifier.trim()),
| None => value.to_string(),
}
}
pub fn identity_key(&self) -> String {
match self.kind {
| PID::ARXIV => {
let identifier = ARXIV::from_string(&self.value).work_identifier();
Self::normalize(&format!("arxiv:{}", identifier.trim_start_matches("arXiv:")))
}
| _ => Self::normalize(&format!("{}:{}", self.kind.as_str(), self.value)),
}
}
fn parsed<T: PersistentIdentifierParse + fmt::Display>(kind: PID, value: &str) -> Option<Self> {
T::find_all(value)
.first()
.map(T::format)
.filter(|value| T::is_valid(value))
.map(|value| Self { kind, value })
}
#[cfg(feature = "std")]
pub fn identifier_hash(&self) -> String {
HEXLOWER.encode(digest(&SHA256, self.value.as_bytes()).as_ref())[..12].to_string()
}
}
impl<'a> From<&'a Identifier> for &'a str {
fn from(identifier: &'a Identifier) -> Self {
identifier.kind.as_str()
}
}
impl From<&str> for Identifier {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<ARK> for Identifier {
fn from(value: ARK) -> Self {
Self {
kind: PID::ARK,
value: value.to_string(),
}
}
}
impl From<ARXIV> for Identifier {
fn from(value: ARXIV) -> Self {
Self {
kind: PID::ARXIV,
value: value.to_string(),
}
}
}
impl From<DOI> for Identifier {
fn from(value: DOI) -> Self {
Self {
kind: PID::DOI,
value: value.to_string(),
}
}
}
impl From<ISBN> for Identifier {
fn from(value: ISBN) -> Self {
Self {
kind: PID::ISBN,
value: value.to_string(),
}
}
}
impl From<ORCID> for Identifier {
fn from(value: ORCID) -> Self {
Self {
kind: PID::ORCID,
value: value.to_string(),
}
}
}
impl From<Patent> for Identifier {
fn from(value: Patent) -> Self {
Self {
kind: PID::Patent,
value: value.to_string(),
}
}
}
impl From<RAID> for Identifier {
fn from(value: RAID) -> Self {
Self {
kind: PID::RAID,
value: value.to_string(),
}
}
}
impl From<ROR> for Identifier {
fn from(value: ROR) -> Self {
Self {
kind: PID::ROR,
value: value.to_string(),
}
}
}
impl PID {
pub fn is_discoverable(&self) -> bool {
self.is_ark()
|| self.is_arxiv()
|| self.is_doi()
|| self.is_isbn()
|| self.is_orcid()
|| self.is_patent()
|| self.is_raid()
|| self.is_ror()
|| self.is_url()
}
pub fn as_str(&self) -> &'static str {
match self {
| Self::DOI => "doi",
| Self::ARXIV => "arxiv",
| Self::ISBN => "isbn",
| Self::ORCID => "orcid",
| Self::Patent => "patent",
| Self::PIDINST => "pidinst",
| Self::ARK => "ark",
| Self::RAID => "raid",
| Self::ROR => "ror",
| Self::URL => "url",
| _ => "unknown",
}
}
pub fn is_project_identifier(&self) -> bool {
self.is_doi() || self.is_arxiv() || self.is_raid() || self.is_isbn() || self.is_patent() || self.is_ark()
}
}
impl fmt::Display for PID {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Serialize for PID {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for PID {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
String::deserialize(deserializer).and_then(|value| {
Self::iter()
.find(|pid| pid.as_str().eq_ignore_ascii_case(&value))
.ok_or_else(|| serde::de::Error::custom(format!("unknown PID type `{value}`")))
})
}
}
impl From<&str> for PID {
fn from(value: &str) -> Self {
Self::iter()
.find(|pid| pid.as_str().eq_ignore_ascii_case(value.trim()))
.unwrap_or_default()
}
}
impl Betanumeric for char {
fn is_betanumeric(&self) -> bool {
BETANUMERIC_DIGITS.contains(*self)
}
fn to_betanumeric_ordinal(&self) -> Option<usize> {
BETANUMERIC_DIGITS.chars().position(|x| x.eq(self))
}
}
impl Default for ARK {
fn default() -> Self {
Self::new()
}
}
impl Default for DOI {
fn default() -> Self {
Self::new()
}
}
impl Default for ARXIV {
fn default() -> Self {
Self::new()
}
}
impl Default for ORCID {
fn default() -> Self {
Self::new()
}
}
impl Default for RAID {
fn default() -> Self {
Self::new()
}
}
impl Default for ROR {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ARK {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let nma = self.name_mapping_authority.clone().unwrap_or_default().trim_end_matches('/').to_string();
let identifier = self.identifier();
let result = [nma, identifier].into_iter().filter(|x| !x.is_empty()).collect::<Vec<String>>().join("/");
write!(f, "{result}")
}
}
impl fmt::Display for DOI {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = self.identifier();
write!(f, "{result}")
}
}
impl fmt::Display for ARXIV {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.identifier())
}
}
impl fmt::Display for ISBN {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = self.identifier();
write!(f, "{result}")
}
}
impl fmt::Display for ORCID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let schema_uri = self.schema_uri();
let identifier = self.identifier();
let uri = if schema_uri.is_empty() { DEFAULT_ORCID_SCHEMA_URI } else { &schema_uri };
let values = match &self.identifier {
| Some(_) => [uri, &identifier].to_vec(),
| None => vec![],
};
let result = values
.into_iter()
.filter(|x| !x.is_empty())
.map(String::from)
.collect::<Vec<String>>()
.join("/");
write!(f, "{result}")
}
}
impl fmt::Display for RAID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = self.identifier();
write!(f, "{result}")
}
}
impl fmt::Display for ROR {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let schema_uri = self.schema_uri();
let result = self.identifier();
if result.is_empty() {
write!(f, "")
} else {
write!(f, "{schema_uri}{result}")
}
}
}
impl PersistentIdentifier for ARK {
fn new() -> Self {
ARK::init().build()
}
fn schema_uri(&self) -> String {
let uri = match &self.name_mapping_authority {
| Some(value) => value,
| None => "",
};
uri.trim_end_matches("/").to_string()
}
fn identifier(&self) -> String {
let values = [self.prefix(), self.suffix()];
values
.iter()
.flatten()
.filter(|x| !x.is_empty())
.map(String::from)
.collect::<Vec<String>>()
.join("/")
}
fn prefix(&self) -> Option<String> {
match (self.name_assigning_authority_number.as_ref(), self.assigned_name.as_ref()) {
| (Some(naan), Some(name)) => Some(format!("{}{}/{}", self.label.trim_end_matches('/'), naan, name)),
| _ => None,
}
}
fn suffix(&self) -> Option<String> {
let parts = self.parts.join("/");
let variants = self.variants.join(".");
let qualifiers = [parts, variants];
let result = qualifiers
.iter()
.filter(|x| !x.is_empty())
.map(String::from)
.collect::<Vec<String>>()
.join(".");
Some(result)
}
fn check_digit(&self) -> Option<Vec<char>> {
let Self {
name_assigning_authority_number: naan,
assigned_name: name,
..
} = self;
let values = [naan.clone(), name.clone()];
if values.iter().all(|x| x.is_some()) {
let value = values.iter().flatten().map(String::from).collect::<Vec<String>>().join("/");
if value.is_empty() {
None
} else {
let trimmed = value.get(..value.len().saturating_sub(1)).unwrap_or_default();
noid_check_digit(trimmed)
}
} else {
None
}
}
}
impl PersistentIdentifier for DOI {
fn new() -> Self {
DOI::init().build()
}
fn schema_uri(&self) -> String {
self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
}
fn identifier(&self) -> String {
let values = [self.prefix(), self.suffix()];
values
.iter()
.flatten()
.filter(|x| !x.is_empty())
.map(String::from)
.collect::<Vec<String>>()
.join("/")
}
fn prefix(&self) -> Option<String> {
let values = [
self.directory_indicator.as_ref().cloned().unwrap_or_default(),
self.registrant_code.as_ref().cloned().unwrap_or_default(),
];
let result = values
.iter()
.filter(|x| !x.is_empty())
.map(String::from)
.collect::<Vec<String>>()
.join(".");
Some(result)
}
fn suffix(&self) -> Option<String> {
fn postprocess(mut value: String) -> String {
if value.ends_with(".") {
value.pop();
}
value
}
let result = self.suffix.as_ref().cloned().unwrap_or_default();
if !result.is_empty() {
Some(postprocess(result))
} else {
None
}
}
fn url(&self) -> String {
let identifier = self.identifier();
if identifier.is_empty() {
String::new()
} else {
let uri = self.schema_uri();
let schema = if uri.is_empty() { DEFAULT_DOI_SCHEMA_URI } else { &uri };
format!("{}/{}", schema, identifier)
}
}
}
impl PersistentIdentifier for ARXIV {
fn new() -> Self {
ARXIV::init().build()
}
fn schema_uri(&self) -> String {
self.schema_uri
.as_ref()
.map(|value| value.trim_end_matches('/').to_string())
.unwrap_or_else(|| DEFAULT_ARXIV_SCHEMA_URI.to_string())
}
fn identifier(&self) -> String {
let work = self.work_identifier();
match (work.is_empty(), self.version.as_ref()) {
| (false, Some(version)) => format!("{work}{version}"),
| _ => work,
}
}
fn prefix(&self) -> Option<String> {
self.archive.clone().or_else(|| {
self.identifier
.as_ref()
.and_then(|value| value.split_once('.').map(|(prefix, _)| prefix.to_string()))
})
}
fn suffix(&self) -> Option<String> {
self.identifier.clone()
}
fn url(&self) -> String {
self.identifier()
.strip_prefix("arXiv:")
.map(|identifier| format!("{}/abs/{identifier}", self.schema_uri()))
.unwrap_or_default()
}
}
impl ARXIV {
pub fn work_identifier(&self) -> String {
self.identifier.as_ref().map_or_else(String::new, |identifier| {
self.archive
.as_ref()
.map_or_else(|| format!("arXiv:{identifier}"), |archive| format!("arXiv:{archive}/{identifier}"))
})
}
}
impl PersistentIdentifier for ISBN {
fn new() -> Self {
ISBN::init().build()
}
fn schema_uri(&self) -> String {
"".to_string()
}
fn identifier(&self) -> String {
let ISBN {
prefix_element,
registration_group,
publisher,
title,
check_digit,
} = self;
[prefix_element, registration_group, publisher, title, check_digit]
.into_iter()
.map(|x| x.clone().unwrap_or_default())
.collect::<Vec<String>>()
.join("-")
}
fn prefix(&self) -> Option<String> {
let ISBN {
prefix_element,
registration_group,
publisher,
..
} = self;
let result = format!(
"{}.{}{}",
prefix_element.clone().unwrap_or_default(),
registration_group.clone().unwrap_or_default(),
publisher.clone().unwrap_or_default()
);
Some(result)
}
fn suffix(&self) -> Option<String> {
let ISBN { title, check_digit, .. } = self;
let result = [title, check_digit]
.into_iter()
.map(|x| x.clone().unwrap_or_default())
.collect::<Vec<String>>()
.join("");
Some(result)
}
fn check_digit(&self) -> Option<Vec<char>> {
isbn_check_digit(self.identifier())
}
}
impl From<ISBN> for DOI {
fn from(isbn: ISBN) -> Self {
DOI::init()
.schema_uri(DEFAULT_DOI_SCHEMA_URI)
.directory_indicator(DATACITE_DOI_DIRECTORY_INDICATOR)
.maybe_registrant_code(isbn.prefix())
.maybe_suffix(isbn.suffix())
.build()
}
}
impl From<ARXIV> for DOI {
fn from(arxiv: ARXIV) -> Self {
let suffix = arxiv.work_identifier().trim_start_matches("arXiv:").to_string();
DOI::init()
.schema_uri(DEFAULT_DOI_SCHEMA_URI)
.directory_indicator(DATACITE_DOI_DIRECTORY_INDICATOR)
.registrant_code(ARXIV_DATACITE_REGISTRANT_CODE)
.suffix(format!("arXiv.{suffix}"))
.build()
}
}
impl TryFrom<DOI> for ARXIV {
type Error = ValidationError;
fn try_from(doi: DOI) -> Result<Self, Self::Error> {
let suffix = doi.suffix().unwrap_or_default();
let arxiv_suffix = suffix
.get(..6)
.filter(|prefix| prefix.eq_ignore_ascii_case("arxiv."))
.and_then(|_| suffix.get(6..));
match (doi.directory_indicator.as_deref(), doi.registrant_code.as_deref(), arxiv_suffix) {
| (Some(DATACITE_DOI_DIRECTORY_INDICATOR), Some(ARXIV_DATACITE_REGISTRANT_CODE), Some(identifier)) => {
let arxiv = ARXIV::from_string(format!("arXiv:{identifier}"));
match ARXIV::is_valid(arxiv.to_string()) {
| true => Ok(arxiv),
| false => Err(ValidationError::new("arxiv_doi")),
}
}
| _ => Err(ValidationError::new("arxiv_doi")),
}
}
}
impl From<DOI> for ISBN {
fn from(doi: DOI) -> Self {
let prefix = doi.prefix().unwrap_or_default().replace(".", "-");
let suffix = match doi.suffix() {
| Some(value) => {
let check_digit = value.chars().last().unwrap_or_default().to_string();
let title = value.get(..value.len().saturating_sub(1)).unwrap_or_default().to_string();
format!("{title}-{check_digit}")
}
| None => "".to_string(),
};
let result = format!("{}-{suffix}", prefix.trim_start_matches("10-"));
ISBN::from_string(result)
}
}
impl PersistentIdentifier for ORCID {
fn new() -> Self {
ORCID::init().build()
}
fn schema_uri(&self) -> String {
self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
}
fn identifier(&self) -> String {
let stripped = self.identifier.as_ref().cloned().unwrap_or_default().replace("-", "");
stripped.chunk(4).join("-")
}
fn suffix(&self) -> Option<String> {
Some(self.identifier())
}
fn check_digit(&self) -> Option<Vec<char>> {
orcid_check_digit(self.identifier())
}
}
impl PersistentIdentifier for RAID {
fn new() -> Self {
RAID::init().build()
}
fn schema_uri(&self) -> String {
self.schema_uri.as_ref().cloned().unwrap_or_default().trim_end_matches("/").to_string()
}
fn prefix(&self) -> Option<String> {
self.prefix.clone()
}
fn suffix(&self) -> Option<String> {
self.suffix.clone()
}
fn identifier(&self) -> String {
let values = [self.prefix(), self.suffix()];
values
.iter()
.flatten()
.filter(|x| !x.is_empty())
.map(String::from)
.collect::<Vec<String>>()
.join("/")
}
}
impl PersistentIdentifier for ROR {
fn new() -> Self {
ROR::init().build()
}
fn schema_uri(&self) -> String {
let processed = self
.schema_uri
.as_ref()
.cloned()
.unwrap_or_else(|| DEFAULT_ROR_SCHEMA_URI.to_string())
.trim_end_matches("/")
.replace(" ", "")
.to_string();
format!("{processed}/")
}
fn identifier(&self) -> String {
self.identifier.clone().unwrap_or_default()
}
fn suffix(&self) -> Option<String> {
self.identifier.clone()
}
fn check_digit(&self) -> Option<Vec<char>> {
self.identifier().get(1..).and_then(ror_check_digit)
}
}
impl<T: AsRef<str>> PersistentIdentifierConvert<T> for T
where
T: ToString,
{
fn format_as(&self, pid_type: PID) -> String {
match pid_type {
| PID::ARK => ARK::format(self.as_ref()),
| PID::ARXIV => ARXIV::format(self.as_ref()),
| PID::DOI => DOI::format(self.as_ref()),
| PID::ORCID => ORCID::format(self.as_ref()),
| PID::RAID => RAID::format(self.as_ref()),
| PID::ROR => <ROR as PersistentIdentifierParse>::format(self.as_ref()),
| _ => self.as_ref().to_string(),
}
}
fn to_pid(&self, pid_type: PID) -> PersistentIdentifierInternal {
let value = self.as_ref().to_string();
match pid_type {
| PID::ARK => PersistentIdentifierInternal { value, pid_type: PID::ARK },
| PID::ARXIV => PersistentIdentifierInternal { value, pid_type: PID::ARXIV },
| PID::DOI => PersistentIdentifierInternal { value, pid_type: PID::DOI },
| PID::ORCID => PersistentIdentifierInternal { value, pid_type: PID::ORCID },
| PID::RAID => PersistentIdentifierInternal { value, pid_type: PID::RAID },
| PID::ROR => PersistentIdentifierInternal { value, pid_type: PID::ROR },
| _ => PersistentIdentifierInternal::default(),
}
}
fn is_pid(&self, pid_type: PID) -> bool {
match pid_type {
| PID::ARK => self.is_ark(),
| PID::ARXIV => self.is_arxiv(),
| PID::DOI => self.is_doi(),
| PID::ORCID => self.is_orcid(),
| PID::RAID => self.is_raid(),
| PID::ROR => self.is_ror(),
| _ => false,
}
}
fn is_ark(&self) -> bool {
ARK::is_valid(self.as_ref())
}
fn is_arxiv(&self) -> bool {
ARXIV::is_valid(self.as_ref())
}
fn is_doi(&self) -> bool {
DOI::is_valid(self.as_ref())
}
fn is_isbn(&self) -> bool {
ISBN::is_valid(self.as_ref())
}
fn is_orcid(&self) -> bool {
ORCID::is_valid(self.as_ref())
}
fn is_raid(&self) -> bool {
RAID::is_valid(self.as_ref())
}
fn is_ror(&self) -> bool {
ROR::is_valid(self.as_ref())
}
}
impl PersistentIdentifierInternal {
pub fn to_ark(&self) -> ARK {
let PersistentIdentifierInternal { value, pid_type } = self;
match pid_type {
| PID::ARK => ARK::from_string(value),
| _ => ARK::default(),
}
}
pub fn to_arxiv(&self) -> ARXIV {
let PersistentIdentifierInternal { value, pid_type } = self;
match pid_type {
| PID::ARXIV => ARXIV::from_string(value),
| _ => ARXIV::default(),
}
}
pub fn to_doi(&self) -> DOI {
let PersistentIdentifierInternal { value, pid_type } = self;
match pid_type {
| PID::DOI => DOI::from_string(value),
| _ => DOI::default(),
}
}
pub fn to_orcid(&self) -> ORCID {
let PersistentIdentifierInternal { value, pid_type } = self;
match pid_type {
| PID::ORCID => ORCID::from_string(value),
| _ => ORCID::default(),
}
}
pub fn to_raid(&self) -> RAID {
let PersistentIdentifierInternal { value, pid_type } = self;
match pid_type {
| PID::RAID => RAID::from_string(value),
| _ => RAID::default(),
}
}
pub fn to_ror(&self) -> ROR {
let PersistentIdentifierInternal { value, pid_type } = self;
match pid_type {
| PID::ROR => ROR::from_string(value),
| _ => ROR::default(),
}
}
}
impl PersistentIdentifierParse for ARK {
fn find_all(value: impl ToString) -> Vec<Self> {
let re = &RE_ARK;
re.find_iter(&value.to_string())
.filter_map(Result::ok)
.map(|m| ARK::from_string(m.as_str()))
.collect()
}
fn format(value: impl ToString) -> String {
ARK::from_string(value.to_string()).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["nma", "label", "naan", "assigned_name", "parts", "variants"];
let pattern = format!("^{RE_ARK_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
let parts = match lookup.get("parts") {
| Some(value) => value.split('/').map(String::from).collect(),
| None => vec![],
};
let variants = match lookup.get("variants") {
| Some(value) => value.split('.').map(String::from).collect(),
| None => vec![],
};
ARK::init()
.maybe_assigned_name(lookup.get("assigned_name").cloned())
.maybe_label(lookup.get("label").cloned())
.maybe_name_assigning_authority_number(lookup.get("naan").cloned())
.maybe_name_mapping_authority(lookup.get("nma").cloned())
.parts(parts)
.variants(variants)
.build()
}
fn is_valid(value: impl ToString) -> bool {
let pid = ARK::from_string(value);
let naan = pid.name_assigning_authority_number.unwrap_or_default();
let naan_is_betanumeric = naan.chars().all(|x| x.is_betanumeric());
let shoulder_starts_with_lowercase_letter = match pid.assigned_name {
| Some(value) => match value.chars().next() {
| Some(value) => value.is_ascii_lowercase() && !value.eq(&'l'),
| None => false,
},
| None => false,
};
!naan.is_empty() && naan_is_betanumeric && shoulder_starts_with_lowercase_letter
}
}
impl PersistentIdentifierParse for ARXIV {
fn find_all(value: impl ToString) -> Vec<Self> {
RE_ARXIV
.find_iter(&value.to_string())
.filter_map(Result::ok)
.map(|matched| matched.as_str().to_string())
.filter(|value| Self::is_valid(value))
.map(Self::from_string)
.collect()
}
fn format(value: impl ToString) -> String {
Self::from_string(value).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["schema_uri", "resource", "archive", "identifier", "version", "pdf"];
let pattern = format!("^{RE_ARXIV_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_str(), text.as_str(), groups.to_vec());
Self::init()
.maybe_schema_uri(lookup.get("schema_uri").map(|_| DEFAULT_ARXIV_SCHEMA_URI.to_string()))
.maybe_archive(lookup.get("archive").map(|value| value.to_ascii_lowercase()))
.maybe_identifier(lookup.get("identifier").cloned())
.maybe_version(lookup.get("version").map(|value| value.to_ascii_lowercase()))
.build()
}
fn is_valid(value: impl ToString) -> bool {
let value = value.to_string();
let complete_match = RE_ARXIV
.find(&value)
.ok()
.flatten()
.is_some_and(|matched| matched.start() == 0 && matched.end() == value.len());
let parsed = Self::from_string(&value);
let resource_is_valid = match (value.to_ascii_lowercase().contains("/abs/"), value.to_ascii_lowercase().ends_with(".pdf")) {
| (true, true) => false,
| (false, true) => value.to_ascii_lowercase().contains("/pdf/"),
| _ => true,
};
let components_are_valid = parsed.identifier.as_deref().is_some_and(|identifier| {
let valid_date = |date: &str| {
let year = date.get(..2).and_then(|value| value.parse::<u16>().ok());
let month = date.get(2..).and_then(|value| value.parse::<u8>().ok());
year.zip(month).filter(|(_, month)| (1..=12).contains(month))
};
match (parsed.archive.as_deref(), identifier.split_once('.')) {
| (None, Some((date, sequence))) => valid_date(date).is_some_and(|(year, month)| {
let yymm = year.saturating_mul(100).saturating_add(u16::from(month));
let width_is_valid = matches!(yymm, 704..=1412) && sequence.len() == 4 || yymm >= 1501 && sequence.len() == 5;
width_is_valid && sequence != "0000" && sequence != "00000"
}),
| (Some(_), None) if identifier.len() == 7 => valid_date(identifier.get(..4).unwrap_or_default()).is_some_and(|(year, month)| {
let date_is_legacy = year > 91 || year == 91 && month >= 7 || year < 7 || year == 7 && month <= 3;
date_is_legacy && identifier.get(4..).is_some_and(|sequence| sequence != "000")
}),
| _ => false,
}
});
complete_match && resource_is_valid && components_are_valid
}
}
impl PersistentIdentifierParse for DOI {
fn find_all(value: impl ToString) -> Vec<Self> {
let re = &RE_DOI;
re.find_iter(&value.to_string())
.filter_map(Result::ok)
.map(|m| DOI::from_string(trim_unmatched_trailing_parentheses(m.as_str())))
.collect()
}
fn format(value: impl ToString) -> String {
DOI::from_string(value).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["schema_uri", "directory_indicator", "prefix_element", "registrant_code", "suffix"];
let pattern = format!("^{RE_DOI_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
DOI::init()
.maybe_schema_uri(lookup.get("schema_uri").cloned())
.maybe_directory_indicator(lookup.get("directory_indicator").cloned())
.maybe_registrant_code(lookup.get("registrant_code").cloned())
.maybe_suffix(lookup.get("suffix").cloned())
.build()
}
fn is_valid(value: impl ToString) -> bool {
let pid = DOI::from_string(value.to_string());
let prefix_is_valid = match pid.prefix() {
| Some(x) => is_numeric(&x.replace(".", "")) && !x.eq("10.5555"),
| _ => false,
};
let suffix_is_valid = pid.suffix().is_some();
prefix_is_valid && suffix_is_valid
}
}
impl PersistentIdentifierParse for ISBN {
fn find_all(value: impl ToString) -> Vec<Self> {
let re = &RE_ISBN;
re.find_iter(&value.to_string())
.filter_map(Result::ok)
.map(|m| ISBN::from_string(m.as_str()))
.collect()
}
fn format(value: impl ToString) -> String {
ISBN::from_string(value).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["prefix_element", "registration_group", "publisher", "title", "check_digit"];
let pattern = format!("^{RE_ISBN_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
ISBN::init()
.maybe_prefix_element(lookup.get("prefix_element").cloned())
.maybe_registration_group(lookup.get("registration_group").cloned())
.maybe_publisher(lookup.get("publisher").cloned())
.maybe_title(lookup.get("title").cloned())
.maybe_check_digit(lookup.get("check_digit").cloned())
.build()
}
fn is_valid(value: impl ToString) -> bool {
let pid = ISBN::from_string(value.to_string());
let last = value.to_string().chars().last().unwrap_or_default();
let has_valid_check_digit = match pid.check_digit() {
| Some(chars) => chars.contains(&last),
| _ => false,
};
let is_valid_length = value.to_string().replace("-", "").len() == 13;
has_valid_check_digit && is_valid_length
}
}
impl PersistentIdentifierParse for ORCID {
fn find_all(value: impl ToString) -> Vec<Self> {
let re = &RE_ORCID;
re.find_iter(&value.to_string())
.filter_map(Result::ok)
.map(|m| ORCID::from_string(m.as_str()))
.collect()
}
fn format(value: impl ToString) -> String {
ORCID::from_string(value).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["schema_uri", "identifier", "check_digit"];
let pattern = format!("^{RE_ORCID_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
ORCID::init()
.maybe_schema_uri(lookup.get("schema_uri").cloned())
.maybe_identifier(lookup.get("identifier").cloned())
.maybe_check_digit(lookup.get("check_digit").cloned())
.build()
}
fn is_valid(value: impl ToString) -> bool {
let pid = ORCID::from_string(value.to_string());
let identifier = pid.identifier();
let last = identifier.chars().last().unwrap_or_default();
match orcid_check_digit(identifier.as_str()) {
| Some(check_digit) => {
if check_digit.contains(&last) {
identifier.len() == 19
} else {
false
}
}
| _ => false,
}
}
}
impl PersistentIdentifierParse for RAID {
fn find_all(value: impl ToString) -> Vec<Self> {
let re = &RE_DOI;
re.find_iter(&value.to_string())
.filter_map(Result::ok)
.map(|m| RAID::from_string(trim_unmatched_trailing_parentheses(m.as_str())))
.collect()
}
fn format(value: impl ToString) -> String {
RAID::from_string(value).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["schema_uri", "directory_indicator", "registrant_code", "suffix"];
let pattern = format!("^{RE_RAID_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
let directory_indicator = lookup.get("directory_indicator").cloned();
let registrant_code = lookup.get("registrant_code").cloned();
let prefix = [directory_indicator, registrant_code]
.into_iter()
.flatten()
.collect::<Vec<String>>()
.join(".");
RAID::init()
.prefix(prefix)
.maybe_schema_uri(lookup.get("schema_uri").cloned())
.maybe_suffix(lookup.get("suffix").cloned())
.build()
}
fn is_valid(value: impl ToString) -> bool {
let pid = RAID::from_string(value.to_string());
let prefix_is_valid = match pid.prefix() {
| Some(x) => is_numeric(&x.replace(".", "")) && !x.eq("10.5555"),
| _ => false,
};
let suffix_is_valid = pid.suffix().is_some();
prefix_is_valid && suffix_is_valid
}
}
impl PersistentIdentifierParse for ROR {
fn find_all(value: impl ToString) -> Vec<Self> {
let re = &RE_ROR;
re.find_iter(&value.to_string())
.filter_map(Result::ok)
.filter(|value| ROR::is_valid(value.as_str()))
.map(|m| ROR::from_string(m.as_str()))
.collect()
}
fn format(value: impl ToString) -> String {
ROR::from_string(value.to_string()).to_string()
}
fn from_string(value: impl ToString) -> Self {
let groups = ["schema_uri", "identifier", "check_digit"];
let pattern = format!("^{RE_ROR_TEXT}$");
let text = value.to_string();
let lookup = regex_capture_lookup(pattern.as_ref(), text.as_ref(), groups.to_vec());
ROR::init()
.maybe_schema_uri(lookup.get("schema_uri").cloned())
.maybe_identifier(lookup.get("identifier").cloned())
.maybe_check_digit(lookup.get("check_digit").cloned())
.build()
}
fn is_valid(value: impl ToString) -> bool {
let pid = ROR::from_string(value.to_string());
let identifier = pid.identifier();
let last_two = identifier.chars().rev().take(2).collect::<String>().chars().rev().collect::<String>();
if identifier.is_empty() {
false
} else {
match ror_check_digit(&identifier[1..]) {
| Some(check_digit) => {
if identifier.len() == 9 {
let calculated_last_two = check_digit.iter().collect::<String>();
calculated_last_two == last_two
} else {
false
}
}
| _ => false,
}
}
}
}
#[allow(clippy::arithmetic_side_effects)]
pub fn isbn_check_digit<S>(_value: S) -> Option<Vec<char>>
where
S: AsRef<str>,
{
const MODULUS: u32 = 10;
let working = _value.as_ref().replace("-", "");
let sum = working.chars().take(12).enumerate().fold(0, |acc, (index, x)| {
let digit = x.to_digit(10).unwrap_or_default();
let multiplier = if index % 2 == 0 { 1 } else { 3 };
acc + (digit * multiplier)
});
let remainder = sum % MODULUS;
let result = if remainder == 0 { 0 } else { MODULUS - remainder };
char::from_digit(result, 10).map(|c| vec![c])
}
#[allow(clippy::arithmetic_side_effects)]
pub fn noid_check_digit<S>(value: S) -> Option<Vec<char>>
where
S: AsRef<str>,
{
const RADIX: usize = 29;
let sum = value.as_ref().chars().enumerate().fold(0, |acc, (i, val)| {
let position = i + 1;
let ordinal = val.to_betanumeric_ordinal().unwrap_or(0);
acc + (position * ordinal)
});
let remainder = sum % RADIX;
to_betanumeric(remainder as u8).map(|c| vec![c])
}
#[allow(clippy::arithmetic_side_effects)]
pub fn orcid_check_digit<S>(value: S) -> Option<Vec<char>>
where
S: AsRef<str>,
{
const MODULUS: u32 = 11;
const RADIX: u32 = 2;
let working = value.as_ref().replace("-", "").replace(" ", "");
let sum = working.chars().take(15).fold(0, |acc, x| {
let digit = x.to_digit(10).unwrap_or_default();
(acc + digit) * RADIX
});
let remainder = sum % MODULUS;
let result = (MODULUS + 1 - remainder) % MODULUS;
if result == 10 {
Some(vec!['X'])
} else {
char::from_digit(result, 10).map(|c| vec![c])
}
}
#[allow(clippy::arithmetic_side_effects)]
pub fn ror_check_digit<S>(value: S) -> Option<Vec<char>>
where
S: AsRef<str>,
{
const MODULUS: u128 = 97;
let working = value
.as_ref()
.replace("-", "")
.replace(" ", "")
.chars()
.take(6)
.map(String::from)
.collect::<Vec<_>>()
.join("");
match base32_crockford_decode(working) {
| Some(value) => {
let remainder = (value * 100) % MODULUS;
let checksum = (MODULUS + 1 - remainder) % MODULUS;
let result = if checksum < 10 {
format!("0{}", checksum).chars().collect()
} else {
checksum.to_string().chars().collect()
};
Some(result)
}
| None => None,
}
}
fn to_betanumeric(value: u8) -> Option<char> {
match BETANUMERIC_DIGITS.chars().enumerate().find(|(i, _)| *i == value as usize) {
| Some((_, x)) => Some(x),
| None => None,
}
}
fn is_numeric(value: &str) -> bool {
value.chars().all(|x| x.is_numeric())
}
#[cfg(test)]
mod tests;