#![cfg_attr(docsrs, feature(doc_cfg))]
use crate::{parse_numbervv, ArticleVersion};
use std::error::Error;
use std::fmt::{Display, Formatter, Result as FmtResult};
pub type ArticleIdResult<'a> = Result<ArticleId<'a>, ArticleIdError>;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArticleIdError {
ExpectedBeginningLiteral,
ExpectedNumberVv,
InvalidMonth,
InvalidYear,
InvalidId,
}
impl Error for ArticleIdError {}
impl Display for ArticleIdError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ExpectedBeginningLiteral => {
f.write_str("Expected the identifier to start with the literal \"arXiv\".")
}
Self::ExpectedNumberVv => {
f.write_str("Expected the identifier to have a component of format .number{{vV}}.")
}
Self::InvalidMonth => f.write_str("A valid month must be between 1 and 12."),
Self::InvalidYear => f.write_str("A valid year must be be between 2007 and 2099."),
Self::InvalidId => f.write_str("A valid identifier must be between 1 and 99999"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ArticleId<'a> {
year: i16,
month: i8,
number: &'a str,
version: ArticleVersion,
}
impl<'a> ArticleId<'a> {
pub const MIN_YEAR: i16 = 2007i16;
pub const MAX_YEAR: i16 = 2099i16;
pub const MIN_NUM_DIGITS: usize = 4usize;
pub const MAX_NUM_DIGITS: usize = 5usize;
pub(crate) const MIN_MONTH: i8 = 1i8;
pub(crate) const MAX_MONTH: i8 = 12i8;
pub(crate) const TOKEN_COLON: char = ':';
pub(crate) const TOKEN_DOT: char = '.';
#[inline]
pub const fn new(year: i16, month: i8, number: &'a str, version: ArticleVersion) -> Self {
Self {
year,
month,
number,
version,
}
}
#[inline]
pub const fn new_latest(year: i16, month: i8, id: &'a str) -> Self {
Self::new(year, month, id, ArticleVersion::Latest)
}
pub const fn new_versioned(year: i16, month: i8, id: &'a str, version: u8) -> Self {
Self::new(year, month, id, ArticleVersion::Num(version))
}
pub fn try_new(
year: i16,
month: i8,
number: &'a str,
version: ArticleVersion,
) -> ArticleIdResult<'a> {
if !(Self::MIN_YEAR..=Self::MAX_YEAR).contains(&year) {
return Err(ArticleIdError::InvalidYear);
}
if !(Self::MIN_MONTH..=Self::MAX_MONTH).contains(&month) {
return Err(ArticleIdError::InvalidMonth);
}
let length_check = (Self::MIN_NUM_DIGITS..=Self::MAX_NUM_DIGITS).contains(&number.len());
let digit_check = number.chars().all(|c| c.is_ascii_digit());
if !length_check || !digit_check {
return Err(ArticleIdError::InvalidId);
}
Ok(Self::new(year, month, number, version))
}
#[inline]
pub fn try_latest(year: i16, month: i8, number: &'a str) -> ArticleIdResult<'a> {
Self::try_new(year, month, number, ArticleVersion::Latest)
}
#[inline]
pub const fn is_latest(&self) -> bool {
matches!(self.version, ArticleVersion::Latest)
}
#[must_use]
#[inline]
pub const fn year(&self) -> i16 {
self.year
}
#[must_use]
#[inline]
pub const fn month(&self) -> i8 {
self.month
}
#[must_use]
#[inline]
pub fn number(&self) -> &'a str {
self.number
}
#[must_use]
#[inline]
pub const fn version(&self) -> ArticleVersion {
self.version
}
#[inline]
pub fn set_version(&mut self, version: u8) {
self.version = ArticleVersion::Num(version)
}
#[inline]
pub fn set_latest(&mut self) {
self.version = ArticleVersion::Latest;
}
pub fn as_unique_ident(&self) -> String {
let mut year_str = self.year.to_string();
let (_, half_year) = year_str.as_mut_str().split_at(2);
match self.number.len() == 4usize {
true => format!("{:02}{:02}.{:04}", half_year, self.month, self.number),
false => format!("{:02}{:02}.{:05}", half_year, self.month, self.number),
}
}
#[cfg(feature = "url")]
#[cfg_attr(docsrs, doc(cfg(feature = "url")))]
pub fn as_url(&self) -> url::Url {
url::Url::from(*self)
}
}
impl Display for ArticleId<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "arXiv:{}{}", self.as_unique_ident(), self.version)
}
}
impl<'a> TryFrom<&'a str> for ArticleId<'a> {
type Error = ArticleIdError;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
use ArticleIdError::*;
let parts: Vec<&str> = value.split(ArticleId::TOKEN_COLON).collect();
if parts.len() != 2 || parts[0] != "arXiv" {
return Err(ExpectedBeginningLiteral);
}
let inner_parts: Vec<&str> = parts[1].split(ArticleId::TOKEN_DOT).collect();
if inner_parts.len() != 2 {
return Err(ExpectedNumberVv);
}
let date = inner_parts[0];
let numbervv = inner_parts[1];
let year = date[0..2].parse::<i16>().map_err(|_| InvalidYear)?;
let month = date[2..4].parse::<i8>().map_err(|_| InvalidMonth)?;
let (number, version) = parse_numbervv(numbervv).ok_or(ExpectedNumberVv)?;
Self::try_new(year + 2000i16, month, number, version)
}
}
#[cfg(feature = "url")]
#[cfg_attr(docsrs, doc(cfg(feature = "url")))]
impl<'a> From<ArticleId<'a>> for url::Url {
fn from(id: ArticleId<'a>) -> Self {
let f = &format!("https://arxiv.org/abs/{}{}", id.as_unique_ident(), id.version);
Self::parse(f).unwrap()
}
}
#[cfg(test)]
mod test_display {
use crate::ArticleId;
#[test]
fn with_version() {
let id = ArticleId::new_versioned(2007, 1, "0001", 1);
assert_eq!(id.to_string(), "arXiv:0701.0001v1");
}
#[test]
fn without_version() {
let id = ArticleId::new_latest(2007, 1, "0001");
assert_eq!(id.to_string(), "arXiv:0701.0001");
}
}
#[cfg(test)]
mod tests_parse_ok {
use crate::{ArticleId, ArticleVersion};
#[test]
fn from_readme() {
let id = ArticleId::try_from("arXiv:0706.0001v1").unwrap();
assert_eq!(id.year, 2007);
assert_eq!(id.month, 6);
assert_eq!(id.number(), "0001");
assert_eq!(id.version(), ArticleVersion::Num(1));
}
#[test]
fn without_version() {
let id = ArticleId::try_from("arXiv:1501.00001");
assert_eq!(id, Ok(ArticleId::new_latest(2015, 1, "00001")));
}
#[test]
fn with_version() {
let id = ArticleId::try_from("arXiv:9912.12345v2");
assert_eq!(id, Ok(ArticleId::new(2099, 12, "12345", ArticleVersion::Num(2))))
}
#[test]
fn with_number_4_digits() {
let id1 = ArticleId::new_latest(2014, 1, "7878");
assert_eq!(id1.to_string(), String::from("arXiv:1401.7878"));
let id2 = ArticleId::new_latest(2014, 12, "7878");
assert_eq!(id2.to_string(), String::from("arXiv:1412.7878"));
}
#[test]
fn with_number_5_digits() {
let id1 = ArticleId::new_latest(2014, 1, "00008");
assert_eq!(id1.to_string(), String::from("arXiv:1401.00008"));
let id2 = ArticleId::new_latest(2014, 12, "00008");
assert_eq!(id2.to_string(), String::from("arXiv:1412.00008"));
}
}
#[cfg(test)]
mod tests_parse_err {
use crate::{ArticleId, ArticleIdError};
#[test]
fn empty_string() {
let id = ArticleId::try_from("");
assert_eq!(id, Err(ArticleIdError::ExpectedBeginningLiteral));
}
#[test]
fn no_numbervv() {
let id = ArticleId::try_from("arXiv:1501");
assert_eq!(id, Err(ArticleIdError::ExpectedNumberVv));
}
#[test]
fn invalid_year() {
let maybe_id = ArticleId::try_latest(2006, 1, "00001");
assert_eq!(maybe_id, Err(ArticleIdError::InvalidYear));
}
#[test]
fn invalid_month() {
let maybe_id = ArticleId::try_latest(2007, i8::MAX, "00001");
assert_eq!(maybe_id, Err(ArticleIdError::InvalidMonth));
}
#[test]
fn invalid_id() {
let maybe_id = ArticleId::try_latest(2007, 11, "");
assert_eq!(maybe_id, Err(ArticleIdError::InvalidId));
}
}
#[cfg(test)]
#[cfg(feature = "url")]
mod tests_url {
use crate::{ArticleId, ArticleVersion};
use url::Url;
#[test]
fn url_from_id() {
let id = ArticleId::try_new(2007, 01, "00001", ArticleVersion::Latest).unwrap();
let url = Url::from(id);
assert_eq!(url.scheme(), "https");
assert_eq!(url.domain(), Some("arxiv.org"));
assert_eq!(url.path(), "/abs/0701.00001");
assert_eq!(url.to_string(), "https://arxiv.org/abs/0701.00001");
}
}