arxiv/
stamp.rs

1use crate::{ArticleId, ArticleIdError, CategoryId};
2use jiff::civil::Date;
3use jiff::fmt::strtime::format as jiff_format;
4use jiff::fmt::strtime::parse as jiff_parse;
5use jiff::Error as JiffError;
6use std::error::Error;
7use std::fmt::{Display, Formatter, Result as FmtResult};
8
9/// Convenient type alias for a [`Result`] holding either a [`Stamp`] or [`StampError`]
10pub type StampResult<'a> = Result<Stamp<'a>, StampError>;
11
12/// An error that can occur when parsing and validating arXiv stamps
13///
14/// # Examples
15/// ```
16/// use arxiv::Stamp;
17///
18/// let stamp = Stamp::try_from("arXiv:2001.00001 [cs.LG] 1 Jan 2000");
19/// ```
20#[non_exhaustive]
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum StampError {
23	InvalidArxivId(ArticleIdError),
24	InvalidDate,
25	InvalidCategory,
26	NotEnoughComponents,
27}
28
29impl Error for StampError {}
30
31impl Display for StampError {
32	fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
33		match self {
34			Self::InvalidArxivId(e) => write!(f, "Invalid arXiv ID: {e}"),
35			Self::InvalidDate => f.write_str("Invalid date"),
36			Self::InvalidCategory => f.write_str("Invalid category"),
37			Self::NotEnoughComponents => f.write_str("Not enough components"),
38		}
39	}
40}
41
42/// A stamp that is added onto the side of PDF version of arXiv articles
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct Stamp<'a> {
45	pub id: ArticleId<'a>,
46	pub category: CategoryId<'a>,
47	pub submitted: Date,
48}
49
50impl<'a> Stamp<'a> {
51	/// Manually create a new [`Stamp`] from the given components.
52	///
53	/// # Examples
54	/// ```
55	/// use arxiv::{Archive, ArticleId, CategoryId, Stamp};
56	/// use jiff::civil::date;
57	///
58	/// let stamp = Stamp::new(
59	///     ArticleId::try_latest(2011, 1, "00001").unwrap(),
60	///     CategoryId::try_new(Archive::Cs, "LG").unwrap(),
61	///     date(2011, 1, 1)
62	/// );
63	/// ```
64	#[inline]
65	pub const fn new(id: ArticleId<'a>, category: CategoryId<'a>, submitted: Date) -> Self {
66		Self {
67			id,
68			category,
69			submitted,
70		}
71	}
72}
73
74impl Display for Stamp<'_> {
75	fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
76		write!(
77			f,
78			"{} [{}] {}",
79			self.id,
80			self.category,
81			jiff_format("%-e %b %Y", self.submitted).map_err(|_| core::fmt::Error)?
82		)
83	}
84}
85
86impl<'a> TryFrom<&'a str> for Stamp<'a> {
87	type Error = StampError;
88
89	fn try_from(s: &'a str) -> Result<Self, Self::Error> {
90		use StampError::*;
91
92		let wsp_indices: Vec<_> = s.match_indices(' ').collect();
93		if wsp_indices.len() < 2 {
94			return Err(NotEnoughComponents);
95		}
96
97		// parse an id
98		let space1 = wsp_indices[0].0;
99		let id = ArticleId::try_from(&s[0..space1]).map_err(InvalidArxivId)?;
100
101		// parse a category
102		let space2 = wsp_indices[1].0;
103		let cat_str = &s[space1 + 1..space2];
104		let category = CategoryId::parse_bracketed(cat_str).ok_or(InvalidCategory)?;
105
106		// parse a date
107		let date_str = &s[space2 + 1..];
108		let date = parse_date(date_str).map_err(|_| InvalidDate)?;
109
110		Ok(Self::new(id, category, date))
111	}
112}
113
114/// Parses a date in the form of "1 Jan 2000", where:
115///  - the day is a number without zero padding
116///  - the month is the first three letters of the full month name
117///  - the year is a 4-digit number
118fn parse_date(date_str: &str) -> Result<Date, JiffError> {
119	jiff_parse("%e %b %Y", date_str)?.to_date()
120}
121
122#[cfg(test)]
123mod tests {
124	use crate::{Archive, ArticleId, CategoryId, Stamp};
125	use jiff::civil::date;
126
127	#[test]
128	fn display_stamp() {
129		let stamp = Stamp::new(
130			ArticleId::try_from("arXiv:2011.00001").unwrap(),
131			CategoryId::try_new(Archive::Cs, "LG").unwrap(),
132			date(2011, 1, 1),
133		);
134		assert_eq!(stamp.to_string(), "arXiv:2011.00001 [cs.LG] 1 Jan 2011");
135	}
136}
137
138#[cfg(test)]
139mod tests_parse_ok {
140	use crate::{Archive, ArticleId, CategoryId, Stamp};
141	use jiff::civil::date;
142
143	#[test]
144	fn parse_stamp() {
145		let stamp = "arXiv:2001.00001 [cs.LG] 1 Jan 2000";
146		let parsed = Stamp::try_from(stamp);
147		assert_eq!(
148			parsed,
149			Ok(Stamp::new(
150				ArticleId::try_from("arXiv:2001.00001").unwrap(),
151				CategoryId::try_new(Archive::Cs, "LG").unwrap(),
152				date(2000, 1, 1)
153			))
154		);
155	}
156
157	#[test]
158	fn parse_stamp_readme() {
159		let stamp = "arXiv:0706.0001v1 [q-bio.CB] 1 Jun 2007";
160		let parsed = Stamp::try_from(stamp);
161
162		assert_eq!(
163			parsed,
164			Ok(Stamp::new(
165				ArticleId::try_from("arXiv:0706.0001v1").unwrap(),
166				CategoryId::try_new(Archive::QBio, "CB").unwrap(),
167				date(2007, 6, 1)
168			))
169		)
170	}
171}
172
173#[cfg(test)]
174mod tests_parse_err {
175	use crate::{Stamp, StampError};
176
177	#[test]
178	fn is_empty() {
179		let stamp = "";
180		let parsed = Stamp::try_from(stamp);
181
182		assert_eq!(parsed, Err(StampError::NotEnoughComponents));
183	}
184
185	#[test]
186	fn not_enough_components() {
187		let stamp = "arXiv:2001.00001";
188		let parsed = Stamp::try_from(stamp);
189
190		assert_eq!(parsed, Err(StampError::NotEnoughComponents));
191	}
192
193	#[test]
194	fn invalid_category() {
195		let stamp = "arXiv:2001.00001 [cs.LG 1 Jan 2000";
196		let parsed = Stamp::try_from(stamp);
197
198		assert_eq!(parsed, Err(StampError::InvalidCategory));
199	}
200
201	#[test]
202	fn invalid_date_day() {
203		let stamp = "arXiv:2001.00001 [cs.LG] 32 Jan 2000";
204		let parsed = Stamp::try_from(stamp);
205
206		assert_eq!(parsed, Err(StampError::InvalidDate));
207	}
208
209	#[test]
210	fn invalid_date_month() {
211		let stamp = "arXiv:2001.00001 [cs.LG] 1 Zan 2000";
212		let parsed = Stamp::try_from(stamp);
213
214		assert_eq!(parsed, Err(StampError::InvalidDate));
215	}
216}