pubmed_parser/pmc/parser/
mod.rs

1use crate::common::{PmcId, PubMedId};
2use crate::error::Result;
3use crate::pmc::domain::{
4    Abstract, ArticleMeta, Back, Body, Front, License, Permissions, PmcArticle, TitleGroup,
5};
6
7pub(crate) mod author;
8pub(crate) mod metadata;
9pub(crate) mod reader_utils;
10pub(crate) mod reference;
11pub(crate) mod section;
12
13/// Extract a section slice from XML content without allocating.
14///
15/// Returns a `&str` slice covering `start_tag..end_tag` (inclusive),
16/// or `None` if the tags are not found.
17fn extract_section_slice<'a>(content: &'a str, start_tag: &str, end_tag: &str) -> Option<&'a str> {
18    let start = content.find(start_tag)?;
19    let end_offset = content[start..].find(end_tag)?;
20    Some(&content[start..start + end_offset + end_tag.len()])
21}
22
23/// Parse PMC XML content into a [`PmcArticle`] domain model.
24///
25/// This function acts as a coordinator that delegates parsing tasks
26/// to specialized parser modules for better maintainability and separation of concerns.
27/// It directly produces domain types without going through legacy intermediate models.
28pub fn parse_pmc_xml(xml_content: &str, pmcid: &str) -> Result<PmcArticle> {
29    let pmcid_typed = PmcId::parse(pmcid)?;
30
31    // Pre-extract major XML sections once to avoid scanning the full document repeatedly.
32    // PMC JATS XML structure: <article> <front>...</front> <body>...</body> <back>...</back> </article>
33    let front = extract_section_slice(xml_content, "<front>", "</front>").unwrap_or(xml_content);
34    let back = extract_section_slice(xml_content, "<back>", "</back>").unwrap_or("");
35
36    // Metadata from <front> (title, journal, dates, IDs, keywords, funding are all in <front>)
37    let title = metadata::extract_title(front);
38    let subtitle = metadata::extract_subtitle(front);
39    let journal = metadata::extract_journal_info(front);
40    let pub_dates = metadata::extract_pub_dates(front);
41    let volume = metadata::extract_volume(front);
42    let issue = metadata::extract_issue(front);
43    let doi = metadata::extract_doi(front);
44    let pmid_str = metadata::extract_pmid(front);
45    let pmid = pmid_str.as_deref().map(PubMedId::parse).transpose()?;
46    let keywords = metadata::extract_keywords(front);
47    let keyword_groups = metadata::extract_keyword_groups(front);
48    let subject_groups = metadata::extract_subject_groups(front);
49    let related_articles = metadata::extract_related_articles(front);
50    let author_notes = metadata::extract_author_notes(front);
51    let funding = metadata::extract_funding(front);
52
53    // Additional metadata from <front>
54    let abstract_text = metadata::extract_abstract(front);
55    let copyright = metadata::extract_copyright(front);
56    let license = metadata::extract_license(front);
57    let license_url = metadata::extract_license_url(front);
58    let history_dates = metadata::extract_history_dates(front);
59    let categories = metadata::extract_categories(front);
60    let fpage = metadata::extract_fpage(front);
61    let lpage = metadata::extract_lpage(front);
62    let elocation_id = metadata::extract_elocation_id(front);
63
64    // Article type is an attribute on the <article> tag itself (before <front>)
65    let article_type = metadata::extract_article_type(xml_content);
66
67    // Back matter
68    let conflict_of_interest = metadata::extract_conflict_of_interest(back);
69    let acknowledgments = metadata::extract_acknowledgments(back);
70    let appendices = section::extract_appendices(back);
71
72    // These can appear in body or back, so search full content
73    let data_availability = metadata::extract_data_availability(xml_content);
74    let supplementary_materials = metadata::extract_supplementary_materials(xml_content);
75
76    // Authors from <front> (contrib-group is in article-meta)
77    let authors = author::extract_authors(front)?;
78
79    // Sections from <body> (extract_sections_enhanced finds <body> internally)
80    let sections = section::extract_sections_enhanced(xml_content);
81
82    // References live in <back> per JATS. Search the already-extracted <back> slice
83    // rather than re-scanning the whole document for `<ref-list>`/`<references>`/`<back>`;
84    // fall back to the full XML only when no <back> element was found.
85    let references_scope = if back.is_empty() { xml_content } else { back };
86    let references = reference::extract_references_detailed(references_scope).unwrap_or_default();
87
88    // Assemble <permissions>: copyright + license
89    let license = if license.is_some() || license_url.is_some() {
90        Some(License {
91            href: license_url,
92            text: license,
93        })
94    } else {
95        None
96    };
97    let permissions = if copyright.is_some() || license.is_some() {
98        Some(Permissions {
99            copyright_statement: copyright,
100            license,
101        })
102    } else {
103        None
104    };
105
106    // Assemble <abstract>* (structured abstract sections not yet extracted)
107    let abstracts = abstract_text
108        .map(|text| {
109            vec![Abstract {
110                abstract_type: None,
111                text,
112                sections: Vec::new(),
113            }]
114        })
115        .unwrap_or_default();
116
117    let front = Front {
118        journal_meta: journal,
119        article_meta: ArticleMeta {
120            pmcid: pmcid_typed,
121            pmid,
122            doi,
123            categories,
124            title_group: TitleGroup {
125                article_title: title,
126                subtitle,
127            },
128            authors,
129            pub_dates,
130            volume,
131            issue,
132            fpage,
133            lpage,
134            elocation_id,
135            history: history_dates,
136            permissions,
137            abstracts,
138            keywords,
139            keyword_groups,
140            subject_groups,
141            related_articles,
142            author_notes,
143            funding,
144        },
145    };
146
147    let body = if xml_content.contains("<body") || !sections.is_empty() {
148        Some(Body { sections })
149    } else {
150        None
151    };
152
153    let has_back_content = acknowledgments.is_some()
154        || conflict_of_interest.is_some()
155        || !references.is_empty()
156        || !appendices.is_empty();
157    let back = if !back.is_empty() || has_back_content {
158        Some(Back {
159            acknowledgments,
160            conflict_of_interest,
161            references,
162            appendices,
163            glossary: Vec::new(),
164        })
165    } else {
166        None
167    };
168
169    Ok(PmcArticle {
170        article_type,
171        front,
172        body,
173        back,
174        supplementary_materials,
175        data_availability,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_parse_basic_structure() {
185        // Test that the parse method successfully delegates to specialized parsers
186        let xml_content = r#"
187        <article xmlns:xlink="http://www.w3.org/1999/xlink" article-type="research-article">
188            <front>
189                <article-meta>
190                    <article-id pub-id-type="pmc">PMC123456</article-id>
191                    <article-id pub-id-type="doi">10.1234/test</article-id>
192                    <title-group>
193                        <article-title>Test Article Title</article-title>
194                    </title-group>
195                    <contrib-group>
196                        <contrib>
197                            <name>
198                                <surname>Doe</surname>
199                                <given-names>John</given-names>
200                            </name>
201                        </contrib>
202                    </contrib-group>
203                    <pub-date>
204                        <year>2023</year>
205                        <month>12</month>
206                        <day>25</day>
207                    </pub-date>
208                </article-meta>
209            </front>
210            <body>
211                <sec>
212                    <title>Introduction</title>
213                    <p>This is the introduction.</p>
214                </sec>
215            </body>
216            <back>
217                <ref-list>
218                    <ref id="ref1">
219                        <element-citation>
220                            <article-title>Reference Title</article-title>
221                        </element-citation>
222                    </ref>
223                </ref-list>
224            </back>
225        </article>
226        "#;
227
228        let result = parse_pmc_xml(xml_content, "PMC123456");
229        assert!(result.is_ok());
230
231        let article = result.unwrap();
232        assert_eq!(article.pmcid().as_str(), "PMC123456");
233        assert_eq!(article.title(), Some("Test Article Title"));
234        assert!(!article.pub_dates().is_empty());
235        assert_eq!(article.pub_dates()[0].year, Some(2023));
236        assert_eq!(article.pub_dates()[0].month, Some(12));
237        assert_eq!(article.pub_dates()[0].day, Some(25));
238        assert!(!article.authors().is_empty());
239        assert!(!article.sections().is_empty());
240        assert!(!article.references().is_empty());
241    }
242
243    #[test]
244    fn test_parse_minimal_xml() {
245        // Test parsing with minimal XML structure
246        let xml_content = r#"
247        <article>
248            <front>
249                <article-meta>
250                    <title-group>
251                        <article-title>Minimal Test</article-title>
252                    </title-group>
253                </article-meta>
254            </front>
255        </article>
256        "#;
257
258        let result = parse_pmc_xml(xml_content, "PMC100000");
259        assert!(result.is_ok());
260
261        let article = result.unwrap();
262        assert_eq!(article.pmcid().as_str(), "PMC100000");
263        assert_eq!(article.title(), Some("Minimal Test"));
264    }
265
266    // Note: Most detailed tests have been moved to the individual parser modules:
267    // - AuthorParser tests in author_parser.rs
268    // - section module functions tests in section/ (one module per JATS element)
269    // - reference module functions tests in reference.rs
270    // - metadata module functions tests in metadata.rs
271    // - shared XML helper tests in common/xml_utils.rs
272}