pubmed_parser/common/
models.rs

1//! Common data models shared across PubMed and PMC modules
2//!
3//! This module provides unified data structures for authors and affiliations
4//! that are used consistently across both PubMed metadata and PMC full-text content.
5
6use serde::{Deserialize, Serialize};
7use std::fmt::{Display, Formatter, Result as FmtResult};
8use std::str::Chars;
9
10/// Represents an author's institutional affiliation
11///
12/// This structure is used across both PubMed and PMC to represent
13/// institutional affiliations in a consistent way.
14#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
15pub struct Affiliation {
16    /// Affiliation ID (optional, commonly used in PMC XML)
17    pub id: Option<String>,
18    /// Institution name (e.g., "Harvard Medical School")
19    pub institution: Option<String>,
20    /// Department or division (e.g., "Department of Medicine")
21    pub department: Option<String>,
22    /// Full address including street, city, state/province
23    pub address: Option<String>,
24    /// Country
25    pub country: Option<String>,
26}
27
28/// Represents a detailed author with enhanced metadata
29///
30/// This structure provides a unified representation of author information
31/// across PubMed and PMC, consolidating various name formats and metadata.
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
33pub struct Author {
34    /// Author's surname (last name)
35    pub surname: Option<String>,
36    /// Author's given names (first name, middle names)
37    pub given_names: Option<String>,
38    /// Author's initials (useful when given_names not available)
39    pub initials: Option<String>,
40    /// Name suffix (e.g., "Jr", "Sr", "III")
41    pub suffix: Option<String>,
42    /// Full formatted name
43    pub full_name: String,
44    /// List of institutional affiliations
45    pub affiliations: Vec<Affiliation>,
46    /// ORCID identifier (e.g., "0000-0000-0000-0000")
47    pub orcid: Option<String>,
48    /// Author's email address
49    pub email: Option<String>,
50    /// Whether this author is a corresponding author
51    pub is_corresponding: bool,
52    /// Author's roles/contributions (e.g., ["Conceptualization", "Writing - original draft"])
53    pub roles: Vec<String>,
54    /// Collaboration / group-author name, when this contributor is a collective
55    /// rather than an individual. From JATS `<collab>` (PMC) or
56    /// `<CollectiveName>` (PubMed). When set, `surname`/`given_names` are
57    /// typically `None` and `full_name` mirrors this value.
58    #[serde(default)]
59    pub collab_name: Option<String>,
60}
61
62impl Display for Author {
63    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
64        write!(f, "{}", self.full_name)
65    }
66}
67
68impl Author {
69    /// Create a new Author with basic information
70    pub fn new(surname: Option<String>, given_names: Option<String>) -> Self {
71        let full_name = format_author_name(&surname, &given_names, &None);
72        Author {
73            surname,
74            given_names,
75            initials: None,
76            suffix: None,
77            full_name,
78            affiliations: Vec::new(),
79            orcid: None,
80            email: None,
81            is_corresponding: false,
82            roles: Vec::new(),
83            collab_name: None,
84        }
85    }
86
87    /// Create an author from a full name string
88    ///
89    /// This is a convenience method for when you have a complete name
90    /// but don't need to separate it into surname and given names.
91    pub fn from_full_name(full_name: String) -> Self {
92        Author {
93            surname: None,
94            given_names: None,
95            initials: None,
96            suffix: None,
97            full_name,
98            affiliations: Vec::new(),
99            orcid: None,
100            email: None,
101            is_corresponding: false,
102            roles: Vec::new(),
103            collab_name: None,
104        }
105    }
106
107    /// Create a collaboration / group author (JATS `<collab>` /
108    /// PubMed `<CollectiveName>`), e.g. "COVID-19 Study Group".
109    pub fn collaboration(name: String) -> Self {
110        Author {
111            surname: None,
112            given_names: None,
113            initials: None,
114            suffix: None,
115            full_name: name.clone(),
116            affiliations: Vec::new(),
117            orcid: None,
118            email: None,
119            is_corresponding: false,
120            roles: Vec::new(),
121            collab_name: Some(name),
122        }
123    }
124
125    /// Whether this contributor is a collaboration / group author rather than
126    /// an individual person.
127    pub fn is_collaboration(&self) -> bool {
128        self.collab_name.is_some()
129    }
130
131    /// Check if the author is affiliated with a specific institution
132    ///
133    /// # Arguments
134    ///
135    /// * `institution` - Institution name to check (case-insensitive)
136    ///
137    /// # Returns
138    ///
139    /// `true` if the author has an affiliation matching the institution
140    pub fn is_affiliated_with(&self, institution: &str) -> bool {
141        let institution_lower = institution.to_lowercase();
142        self.affiliations.iter().any(|affil| {
143            affil
144                .institution
145                .as_ref()
146                .is_some_and(|inst| inst.to_lowercase().contains(&institution_lower))
147        })
148    }
149
150    /// Get the author's primary affiliation (first in the list)
151    ///
152    /// # Returns
153    ///
154    /// A reference to the primary affiliation, if any
155    pub fn primary_affiliation(&self) -> Option<&Affiliation> {
156        self.affiliations.first()
157    }
158
159    /// Check if the author has an ORCID identifier
160    ///
161    /// # Returns
162    ///
163    /// `true` if the author has an ORCID ID
164    pub fn has_orcid(&self) -> bool {
165        self.orcid.is_some()
166    }
167
168    /// Check if the author name is empty
169    ///
170    /// # Returns
171    ///
172    /// `true` if the full name is empty or just whitespace
173    pub fn is_empty(&self) -> bool {
174        self.full_name.trim().is_empty()
175    }
176
177    /// Get the length of the author's full name
178    ///
179    /// # Returns
180    ///
181    /// Length of the full name string
182    pub fn len(&self) -> usize {
183        self.full_name.len()
184    }
185
186    /// Get an iterator over the characters in the author's full name
187    ///
188    /// # Returns
189    ///
190    /// Iterator over characters
191    pub fn chars(&self) -> Chars<'_> {
192        self.full_name.chars()
193    }
194}
195
196impl Affiliation {
197    /// Create a new Affiliation instance
198    pub fn new(institution: Option<String>) -> Self {
199        Self {
200            id: None,
201            institution,
202            department: None,
203            address: None,
204            country: None,
205        }
206    }
207}
208
209/// Format an author name from components
210///
211/// # Arguments
212///
213/// * `surname` - Author's surname (last name)
214/// * `given_names` - Author's given names (first, middle)
215/// * `initials` - Author's initials (used if given_names is missing)
216///
217/// # Returns
218///
219/// Formatted full name following these rules:
220/// 1. If both given_names and surname exist: "GivenNames Surname"
221/// 2. If only surname exists: "Initials Surname" (if initials available) or "Surname"
222/// 3. If only given_names exists: "GivenNames"
223/// 4. If neither exists: "Unknown Author"
224pub fn format_author_name(
225    surname: &Option<String>,
226    given_names: &Option<String>,
227    initials: &Option<String>,
228) -> String {
229    match (given_names, surname) {
230        (Some(given), Some(sur)) => format!("{given} {sur}"),
231        (None, Some(sur)) => {
232            if let Some(init) = initials {
233                format!("{init} {sur}")
234            } else {
235                sur.clone()
236            }
237        }
238        (Some(given), None) => given.clone(),
239        (None, None) => "Unknown Author".to_string(),
240    }
241}
242
243/// Structured publication date.
244///
245/// Used by both PubMed and PMC articles. A single article can have multiple
246/// publication dates distinguished by `pub_type` (e.g., electronic vs. print).
247///
248/// PubMed: Maps to `<PubDate>` inside `<JournalIssue>`.
249/// PMC/JATS: Maps to `<pub-date pub-type="...">`.
250#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
251pub struct PublicationDate {
252    /// Publication type. Common values: `"epub"`, `"ppub"`, `"collection"`, `"medline"`.
253    pub pub_type: Option<String>,
254    /// Year. From `<Year>`.
255    pub year: Option<u16>,
256    /// Month (1-12). From `<Month>`.
257    pub month: Option<u8>,
258    /// Day (1-31). From `<Day>`.
259    pub day: Option<u8>,
260}
261
262/// Publication history date.
263///
264/// Used by both PubMed and PMC articles for dates like received, accepted, revised.
265///
266/// PubMed: Maps to `<History>/<PubMedPubDate>`.
267/// PMC/JATS: Maps to `<history>/<date date-type="...">`.
268#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
269pub struct HistoryDate {
270    /// Date type. Common values: `"received"`, `"accepted"`, `"rev-recd"`,
271    /// `"pubmed"`, `"medline"`, `"entrez"`.
272    pub date_type: String,
273    /// Year. From `<Year>`.
274    pub year: Option<u16>,
275    /// Month (1-12). From `<Month>`.
276    pub month: Option<u8>,
277    /// Day (1-31). From `<Day>`.
278    pub day: Option<u8>,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_author_creation() {
287        let author = Author::new(Some("Smith".to_string()), Some("Jane".to_string()));
288        assert_eq!(author.surname, Some("Smith".to_string()));
289        assert_eq!(author.given_names, Some("Jane".to_string()));
290        assert_eq!(author.full_name, "Jane Smith");
291        assert!(!author.has_orcid());
292        assert!(!author.is_corresponding);
293    }
294
295    #[test]
296    fn test_author_affiliations() {
297        let mut author = Author::new(Some("Doe".to_string()), Some("John".to_string()));
298        author.affiliations.push(Affiliation {
299            id: None,
300            institution: Some("Harvard Medical School".to_string()),
301            department: Some("Department of Medicine".to_string()),
302            address: Some("Boston, MA".to_string()),
303            country: Some("USA".to_string()),
304        });
305
306        assert!(author.is_affiliated_with("Harvard"));
307        assert!(!author.is_affiliated_with("Stanford"));
308
309        let primary = author.primary_affiliation().unwrap();
310        assert_eq!(
311            primary.institution,
312            Some("Harvard Medical School".to_string())
313        );
314    }
315
316    #[test]
317    fn test_format_author_name() {
318        assert_eq!(
319            format_author_name(&Some("Smith".to_string()), &Some("John".to_string()), &None),
320            "John Smith"
321        );
322
323        assert_eq!(
324            format_author_name(&Some("Doe".to_string()), &None, &Some("J".to_string())),
325            "J Doe"
326        );
327
328        assert_eq!(
329            format_author_name(&Some("Johnson".to_string()), &None, &None),
330            "Johnson"
331        );
332
333        assert_eq!(
334            format_author_name(&None, &Some("Jane".to_string()), &None),
335            "Jane"
336        );
337
338        assert_eq!(format_author_name(&None, &None, &None), "Unknown Author");
339    }
340
341    #[test]
342    fn test_affiliation_creation() {
343        let affil = Affiliation::new(Some("MIT".to_string()));
344        assert_eq!(affil.institution, Some("MIT".to_string()));
345        assert_eq!(affil.id, None);
346        assert_eq!(affil.department, None);
347    }
348}