pubmed_client/europe_pmc/
id.rs

1//! Source/identifier addressing for Europe PMC records.
2//!
3//! Europe PMC addresses every record by a `(source, id)` pair, e.g.
4//! `MED/12345`, `PMC/PMC3258128`, or `PPR/PPR123456`. These types provide a
5//! typed, validated way to construct those addresses for the REST API.
6
7use std::fmt;
8use std::str::FromStr;
9
10use crate::common::PmcId;
11use crate::error::{PubMedError, Result};
12
13/// A Europe PMC source database.
14///
15/// The known variants cover the commonly used databases; any unrecognized code
16/// is preserved in [`EuropePmcSource::Other`] so new sources never break
17/// parsing or addressing.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub enum EuropePmcSource {
20    /// PubMed / MEDLINE (`MED`).
21    Med,
22    /// PubMed Central (`PMC`).
23    Pmc,
24    /// Preprints (`PPR`).
25    Ppr,
26    /// Agricola (`AGR`).
27    Agr,
28    /// Chinese Biological Abstracts (`CBA`).
29    Cba,
30    /// Patents (`PAT`).
31    Pat,
32    /// NHS Evidence / ETHoS / other recognized-but-uncommon, or any code not
33    /// otherwise modelled. Stores the raw uppercase source code.
34    Other(String),
35}
36
37impl EuropePmcSource {
38    /// Return the uppercase source code used by the REST API (e.g. `"MED"`).
39    pub fn as_str(&self) -> &str {
40        match self {
41            EuropePmcSource::Med => "MED",
42            EuropePmcSource::Pmc => "PMC",
43            EuropePmcSource::Ppr => "PPR",
44            EuropePmcSource::Agr => "AGR",
45            EuropePmcSource::Cba => "CBA",
46            EuropePmcSource::Pat => "PAT",
47            EuropePmcSource::Other(code) => code,
48        }
49    }
50}
51
52impl fmt::Display for EuropePmcSource {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.as_str())
55    }
56}
57
58impl FromStr for EuropePmcSource {
59    // Parsing is in fact infallible (unknown codes map to `Other`), but the
60    // crate `Result` alias fixes the error type to `PubMedError`, so we use it
61    // for consistency and to satisfy the `absolute_paths` lint.
62    type Err = PubMedError;
63
64    fn from_str(s: &str) -> Result<Self> {
65        let upper = s.trim().to_ascii_uppercase();
66        Ok(match upper.as_str() {
67            "MED" => EuropePmcSource::Med,
68            "PMC" => EuropePmcSource::Pmc,
69            "PPR" => EuropePmcSource::Ppr,
70            "AGR" => EuropePmcSource::Agr,
71            "CBA" => EuropePmcSource::Cba,
72            "PAT" => EuropePmcSource::Pat,
73            _ => EuropePmcSource::Other(upper),
74        })
75    }
76}
77
78impl From<&str> for EuropePmcSource {
79    fn from(s: &str) -> Self {
80        // FromStr never returns Err for a source code.
81        s.parse()
82            .unwrap_or_else(|_| EuropePmcSource::Other(s.trim().to_ascii_uppercase()))
83    }
84}
85
86/// A fully-qualified Europe PMC record address: a `(source, id)` pair.
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct EuropePmcId {
89    /// The source database.
90    pub source: EuropePmcSource,
91    /// The record identifier within that source (e.g. a PMID for `MED`, or a
92    /// `PMCnnn` id for `PMC`).
93    pub id: String,
94}
95
96impl EuropePmcId {
97    /// Construct an address from an explicit source and id.
98    pub fn new(source: EuropePmcSource, id: impl Into<String>) -> Self {
99        Self {
100            source,
101            id: id.into(),
102        }
103    }
104
105    /// Construct a `PMC`-sourced address, normalizing the id to `PMCnnn` form.
106    ///
107    /// Accepts ids with or without the `PMC` prefix.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the id is not a valid PMC id.
112    pub fn pmc(id: &str) -> Result<Self> {
113        let pmc_id = PmcId::parse(id)?;
114        Ok(Self {
115            source: EuropePmcSource::Pmc,
116            id: pmc_id.as_str(),
117        })
118    }
119
120    /// Construct a `MED`-sourced (PubMed) address from a PMID.
121    pub fn med(pmid: impl Into<String>) -> Self {
122        Self {
123            source: EuropePmcSource::Med,
124            id: pmid.into(),
125        }
126    }
127
128    /// Resolve the `(source, id)` pair a caller addressed.
129    ///
130    /// Europe PMC identifies every record by a source database plus an id, but
131    /// requiring callers to spell both out for the common cases would be
132    /// noise, so three forms are accepted:
133    ///
134    /// * a fully-qualified `"SOURCE/ID"` string (e.g. `"PPR/PPR123456"`),
135    ///   which takes precedence over any `source` argument;
136    /// * an explicit `source` plus a bare id;
137    /// * a bare id alone — a `PMC`-prefixed id implies the `PMC` source,
138    ///   anything else is treated as a PubMed (`MED`) record.
139    ///
140    /// Every language binding routes its own id arguments through this so the
141    /// three forms mean the same thing on every surface.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the id is blank, or if a qualified or `PMC`-sourced
146    /// id is malformed.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use pubmed_client::{EuropePmcId, EuropePmcSource};
152    ///
153    /// // A bare PMC id implies the PMC source.
154    /// let id = EuropePmcId::resolve("PMC3258128", None)?;
155    /// assert_eq!(id.to_string(), "PMC/PMC3258128");
156    ///
157    /// // A bare non-PMC id is treated as a PubMed record.
158    /// let id = EuropePmcId::resolve("33515491", None)?;
159    /// assert_eq!(id.to_string(), "MED/33515491");
160    ///
161    /// // A qualified id wins over the source argument.
162    /// let id = EuropePmcId::resolve("PPR/PPR123456", Some("MED"))?;
163    /// assert_eq!(id.source, EuropePmcSource::Ppr);
164    /// # Ok::<(), pubmed_client::PubMedError>(())
165    /// ```
166    pub fn resolve(id: &str, source: Option<&str>) -> Result<Self> {
167        let id = id.trim();
168        if id.is_empty() {
169            return Err(PubMedError::InvalidQuery(
170                "Europe PMC id must not be empty".to_string(),
171            ));
172        }
173
174        if id.contains('/') {
175            return id.parse();
176        }
177
178        let source = match source {
179            Some(source) if !source.trim().is_empty() => EuropePmcSource::from(source),
180            _ if id.to_ascii_uppercase().starts_with("PMC") => EuropePmcSource::Pmc,
181            _ => EuropePmcSource::Med,
182        };
183
184        if source == EuropePmcSource::Pmc {
185            return Self::pmc(id);
186        }
187
188        Ok(Self::new(source, id))
189    }
190
191    /// Return the PMC id (`PMCnnn`) for this address if it is PMC-sourced.
192    pub(crate) fn pmcid(&self) -> Option<String> {
193        match self.source {
194            EuropePmcSource::Pmc => Some(self.id.clone()),
195            _ => None,
196        }
197    }
198}
199
200impl fmt::Display for EuropePmcId {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "{}/{}", self.source, self.id)
203    }
204}
205
206impl FromStr for EuropePmcId {
207    type Err = PubMedError;
208
209    /// Parse a `"SOURCE/ID"` string, e.g. `"PMC/PMC3258128"` or `"MED/12345"`.
210    fn from_str(s: &str) -> Result<Self> {
211        let (source, id) = s.trim().split_once('/').ok_or_else(|| {
212            PubMedError::InvalidQuery(format!(
213                "invalid Europe PMC id {s:?}: expected \"SOURCE/ID\" form"
214            ))
215        })?;
216        if id.is_empty() {
217            return Err(PubMedError::InvalidQuery(format!(
218                "invalid Europe PMC id {s:?}: empty record id"
219            )));
220        }
221        Ok(Self {
222            source: EuropePmcSource::from(source),
223            id: id.to_string(),
224        })
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_source_roundtrip() {
234        assert_eq!(EuropePmcSource::Med.as_str(), "MED");
235        assert_eq!(
236            "pmc".parse::<EuropePmcSource>().unwrap(),
237            EuropePmcSource::Pmc
238        );
239        assert_eq!(
240            "xyz".parse::<EuropePmcSource>().unwrap(),
241            EuropePmcSource::Other("XYZ".to_string())
242        );
243    }
244
245    #[test]
246    fn test_pmc_normalizes() {
247        let id = EuropePmcId::pmc("3258128").unwrap();
248        assert_eq!(id.source, EuropePmcSource::Pmc);
249        assert_eq!(id.id, "PMC3258128");
250        assert_eq!(id.to_string(), "PMC/PMC3258128");
251        assert_eq!(id.pmcid().as_deref(), Some("PMC3258128"));
252    }
253
254    #[test]
255    fn test_med_has_no_pmcid() {
256        let id = EuropePmcId::med("12345");
257        assert_eq!(id.to_string(), "MED/12345");
258        assert!(id.pmcid().is_none());
259    }
260
261    #[test]
262    fn test_parse_from_str() {
263        let id: EuropePmcId = "PMC/PMC3258128".parse().unwrap();
264        assert_eq!(id.source, EuropePmcSource::Pmc);
265        assert_eq!(id.id, "PMC3258128");
266
267        let med: EuropePmcId = "MED/12345".parse().unwrap();
268        assert_eq!(med.source, EuropePmcSource::Med);
269
270        assert!("nodelimiter".parse::<EuropePmcId>().is_err());
271        assert!("PMC/".parse::<EuropePmcId>().is_err());
272    }
273
274    #[test]
275    fn test_resolve_bare_pmc_id_defaults_to_pmc_source() {
276        let id = EuropePmcId::resolve("PMC3258128", None).unwrap();
277        assert_eq!(id.to_string(), "PMC/PMC3258128");
278    }
279
280    #[test]
281    fn test_resolve_bare_numeric_id_defaults_to_med_source() {
282        let id = EuropePmcId::resolve("33515491", None).unwrap();
283        assert_eq!(id.to_string(), "MED/33515491");
284    }
285
286    #[test]
287    fn test_resolve_explicit_pmc_source_normalizes_a_bare_number() {
288        assert_eq!(
289            EuropePmcId::resolve("3258128", Some("pmc"))
290                .unwrap()
291                .to_string(),
292            "PMC/PMC3258128"
293        );
294    }
295
296    #[test]
297    fn test_resolve_qualified_id_wins_over_source_argument() {
298        let id = EuropePmcId::resolve("PPR/PPR123456", Some("MED")).unwrap();
299        assert_eq!(id.source, EuropePmcSource::Ppr);
300    }
301
302    #[test]
303    fn test_resolve_blank_source_falls_back_to_the_bare_id_rule() {
304        let id = EuropePmcId::resolve("PMC3258128", Some("   ")).unwrap();
305        assert_eq!(id.source, EuropePmcSource::Pmc);
306    }
307
308    #[test]
309    fn test_resolve_rejects_invalid_ids() {
310        assert!(EuropePmcId::resolve("   ", None).is_err());
311        assert!(EuropePmcId::resolve("MED/", None).is_err());
312    }
313}