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}