Skip to main content

figshare_rs/
ids.rs

1//! Small identifier newtypes used throughout the public API.
2
3use 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            /// Raw numeric identifier returned by Figshare.
17            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    /// Identifier for a Figshare article.
54    ArticleId
55);
56id_newtype!(
57    /// Identifier for a file attached to a Figshare article.
58    FileId
59);
60id_newtype!(
61    /// Identifier for a Figshare category.
62    CategoryId
63);
64id_newtype!(
65    /// Identifier for a Figshare license.
66    LicenseId
67);
68
69/// DOI string wrapper used by public article selectors and response payloads.
70#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
71#[serde(transparent)]
72pub struct Doi(
73    /// Raw DOI value.
74    pub String,
75);
76
77/// Errors raised while parsing or validating DOI selectors.
78#[derive(Clone, Debug, PartialEq, Eq, Error)]
79pub enum DoiError {
80    /// The normalized DOI string was empty.
81    #[error("DOI cannot be empty")]
82    Empty,
83    /// The DOI did not match the expected `10.<registrant>/<suffix>` shape.
84    #[error("invalid DOI: {0}")]
85    Invalid(String),
86}
87
88impl Doi {
89    /// Creates a normalized DOI wrapper from a raw DOI-like input.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if the normalized value does not resemble a DOI.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use figshare_rs::Doi;
99    ///
100    /// let doi = Doi::new(" HTTPS://DOI.ORG/10.6084/M9.FIGSHARE.123 ")?;
101    /// assert_eq!(doi.as_str(), "10.6084/m9.figshare.123");
102    /// # Ok::<(), figshare_rs::DoiError>(())
103    /// ```
104    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    /// Returns the raw DOI string.
111    #[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}