1use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use thiserror::Error;
8
9use crate::serde_util::deserialize_u64ish;
10
11macro_rules! id_newtype {
12 ($(#[$meta:meta])* $name:ident) => {
13 $(#[$meta])*
14 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
15 pub struct $name(
16 pub u64
18 );
19
20 impl fmt::Display for $name {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 write!(f, "{}", self.0)
23 }
24 }
25
26 impl From<u64> for $name {
27 fn from(value: u64) -> Self {
28 Self(value)
29 }
30 }
31
32 impl Serialize for $name {
33 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
34 where
35 S: Serializer,
36 {
37 serializer.serialize_u64(self.0)
38 }
39 }
40
41 impl<'de> Deserialize<'de> for $name {
42 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
43 where
44 D: Deserializer<'de>,
45 {
46 deserialize_u64ish(deserializer).map(Self)
47 }
48 }
49 };
50}
51
52id_newtype!(
53 ArticleId
55);
56id_newtype!(
57 FileId
59);
60id_newtype!(
61 CategoryId
63);
64id_newtype!(
65 LicenseId
67);
68
69#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
71#[serde(transparent)]
72pub struct Doi(
73 pub String,
75);
76
77#[derive(Clone, Debug, PartialEq, Eq, Error)]
79pub enum DoiError {
80 #[error("DOI cannot be empty")]
82 Empty,
83 #[error("invalid DOI: {0}")]
85 Invalid(String),
86}
87
88impl Doi {
89 pub fn new(value: impl AsRef<str>) -> Result<Self, DoiError> {
105 let normalized = normalize_doi(value.as_ref());
106 validate_doi(&normalized)?;
107 Ok(Self(normalized))
108 }
109
110 #[must_use]
112 pub fn as_str(&self) -> &str {
113 &self.0
114 }
115}
116
117impl fmt::Display for Doi {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 self.0.fmt(f)
120 }
121}
122
123impl TryFrom<String> for Doi {
124 type Error = DoiError;
125
126 fn try_from(value: String) -> Result<Self, Self::Error> {
127 Self::new(value)
128 }
129}
130
131impl TryFrom<&str> for Doi {
132 type Error = DoiError;
133
134 fn try_from(value: &str) -> Result<Self, Self::Error> {
135 Self::new(value)
136 }
137}
138
139impl FromStr for Doi {
140 type Err = DoiError;
141
142 fn from_str(s: &str) -> Result<Self, Self::Err> {
143 Self::new(s)
144 }
145}
146
147impl<'de> Deserialize<'de> for Doi {
148 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149 where
150 D: Deserializer<'de>,
151 {
152 let value = String::deserialize(deserializer)?;
153 Self::new(value).map_err(serde::de::Error::custom)
154 }
155}
156
157fn normalize_doi(value: &str) -> String {
158 let trimmed = value.trim();
159 let without_prefix = trim_doi_prefix(trimmed);
160 without_prefix.trim().to_ascii_lowercase()
161}
162
163fn trim_doi_prefix(value: &str) -> &str {
164 const PREFIXES: [&str; 4] = [
165 "doi:",
166 "https://doi.org/",
167 "http://doi.org/",
168 "https://dx.doi.org/",
169 ];
170
171 for prefix in PREFIXES {
172 if value.len() >= prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) {
173 return &value[prefix.len()..];
174 }
175 }
176
177 value
178}
179
180fn validate_doi(value: &str) -> Result<(), DoiError> {
181 if value.is_empty() {
182 return Err(DoiError::Empty);
183 }
184
185 let Some((registrant, suffix)) = value.split_once('/') else {
186 return Err(DoiError::Invalid(value.to_owned()));
187 };
188
189 if registrant.len() <= 3 || !registrant.starts_with("10.") || suffix.is_empty() {
190 return Err(DoiError::Invalid(value.to_owned()));
191 }
192
193 Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198 use super::{ArticleId, CategoryId, Doi, DoiError, FileId, LicenseId};
199
200 #[test]
201 fn numeric_ids_deserialize_from_strings_and_numbers() {
202 let article: ArticleId = serde_json::from_str("\"12\"").unwrap();
203 let file: FileId = serde_json::from_str("13").unwrap();
204 let category: CategoryId = serde_json::from_str("\"14\"").unwrap();
205 let license: LicenseId = serde_json::from_str("15.0").unwrap();
206
207 assert_eq!(article.0, 12);
208 assert_eq!(file.0, 13);
209 assert_eq!(category.0, 14);
210 assert_eq!(license.0, 15);
211 }
212
213 #[test]
214 fn doi_round_trips_through_display_and_parse() {
215 let doi: Doi = "10.6084/m9.figshare.123".parse().unwrap();
216 assert_eq!(doi.as_str(), "10.6084/m9.figshare.123");
217 assert_eq!(doi.to_string(), "10.6084/m9.figshare.123");
218 }
219
220 #[test]
221 fn numeric_ids_serialize_and_convert_from_u64() {
222 let article = ArticleId::from(21);
223 let file = FileId::from(22);
224 let category = CategoryId::from(23);
225 let license = LicenseId::from(24);
226
227 assert_eq!(article.to_string(), "21");
228 assert_eq!(serde_json::to_string(&file).unwrap(), "22");
229 assert_eq!(serde_json::to_string(&category).unwrap(), "23");
230 assert_eq!(serde_json::to_string(&license).unwrap(), "24");
231 }
232
233 #[test]
234 fn doi_normalization_trims_prefixes_and_case() {
235 assert_eq!(
236 Doi::new(" HTTPS://DOI.ORG/10.6084/M9.FIGSHARE.123 ")
237 .unwrap()
238 .as_str(),
239 "10.6084/m9.figshare.123"
240 );
241 assert_eq!(
242 Doi::new("doi:10.6084/M9.FIGSHARE.456").unwrap().as_str(),
243 "10.6084/m9.figshare.456"
244 );
245 assert_eq!(
246 Doi::new("https://dx.doi.org/10.6084/M9.FIGSHARE.789")
247 .unwrap()
248 .as_str(),
249 "10.6084/m9.figshare.789"
250 );
251 }
252
253 #[test]
254 fn doi_validation_rejects_empty_or_invalid_values() {
255 assert_eq!(Doi::new(" ").unwrap_err(), DoiError::Empty);
256 assert!(matches!(
257 Doi::new("figshare.123").unwrap_err(),
258 DoiError::Invalid(value) if value == "figshare.123"
259 ));
260 }
261}