Skip to main content

crossref_xml/
lib.rs

1use crate::{
2    body::Body,
3    head::{Depositor, Head},
4    serializers::*,
5};
6use serde::Serialize;
7use validator::Validate;
8
9pub mod body;
10pub mod enums;
11pub mod head;
12pub mod journal;
13pub mod regex;
14pub mod serializers;
15
16#[derive(Debug, Clone, Default, Serialize, Validate)]
17#[serde(rename = "doi_batch")]
18pub struct DoiBatch {
19    #[serde(rename = "@version", serialize_with = "serialize_version")]
20    pub version: (),
21    #[serde(rename = "@xmlns", serialize_with = "serialize_xmlns")]
22    pub xmlns: (),
23    #[serde(rename = "@xmlns:xsi", serialize_with = "serialize_xmlns_xsi")]
24    pub xmlns_xsi: (),
25    #[serde(
26        rename = "@xsi:schemaLocation",
27        serialize_with = "serialize_xsi_schemalocation"
28    )]
29    pub xsi_schemalocation: (),
30    #[serde(rename = "@xmlns:jats", serialize_with = "serialize_xmlns_jats")]
31    pub xmlns_jats: (),
32    #[validate(nested)]
33    pub head: Head,
34    #[serde(skip_serializing_if = "Body::is_empty")]
35    #[validate(nested)]
36    pub body: Body,
37}
38
39impl DoiBatch {
40    pub fn new(depositor_name: String, depositor_email: String) -> Self {
41        Self {
42            head: Head {
43                depositor: Depositor {
44                    depositor_name: depositor_name.clone(),
45                    email_address: depositor_email,
46                },
47                registrant: depositor_name,
48                ..Default::default()
49            },
50            body: Body {
51                ..Default::default()
52            },
53            ..Default::default()
54        }
55    }
56
57    /// Add a journal to the DoiBatch
58    pub fn add_journal(&mut self, journal: journal::Journal) {
59        self.body.journal.push(journal);
60    }
61
62    pub fn to_xml(&self) -> Result<String, quick_xml::DeError> {
63        quick_xml::se::to_string(self)
64    }
65}
66
67pub mod builders {
68    use crate::enums::{
69        ContentVersion, ContributorRole, Iso639_1, MediaTypeDate, ParentRelation, Sequence,
70    };
71    use crate::journal::Journal;
72    use crate::journal::article::{Component, JournalArticle};
73    use crate::journal::issue::{Contributor, JournalIssue, PersonName, PublicationDate, Titles};
74    use crate::journal::metadata::{ArchiveLocations, DoiData, JournalMetadata, Resource};
75
76    impl Journal {
77        /// Create a minimal journal with only required fields
78        pub fn minimal(full_title: String, journal_doi: String, journal_url: String) -> Self {
79            Self {
80                journal_metadata: JournalMetadata {
81                    lang: Iso639_1::En,
82                    full_title,
83                    abbrev_title: None,
84                    issn: None,
85                    coden: None,
86                    archive_locations: ArchiveLocations::default(),
87                    doi_data: DoiData {
88                        doi: journal_doi,
89                        timestamp: (),
90                        resource: Resource {
91                            value: journal_url,
92                            mime_type: None,
93                            content_version: ContentVersion::Vor,
94                        },
95                    },
96                },
97                journal_issue: None,
98                journal_article: vec![],
99            }
100        }
101
102        pub fn add_issue(&mut self, issue: JournalIssue) {
103            self.journal_issue = Some(issue);
104        }
105
106        pub fn add_article(&mut self, article: JournalArticle) {
107            self.journal_article.push(article);
108        }
109    }
110
111    impl JournalIssue {
112        /// Create a minimal journal issue with only required fields
113        pub fn minimal(title: String, year: i32, issue_doi: String, issue_url: String) -> Self {
114            Self {
115                contributors: vec![],
116                titles: Some(Titles {
117                    title,
118                    subtitle: None,
119                    original_language_title: None,
120                    orginal_language_subtitle: None,
121                }),
122                publication_date: PublicationDate {
123                    media_type: MediaTypeDate::Online,
124                    month: None,
125                    day: None,
126                    year,
127                },
128                issue: None,
129                special_numbering: None,
130                archive_locations: ArchiveLocations::default(),
131                doi_data: DoiData {
132                    doi: issue_doi,
133                    timestamp: (),
134                    resource: Resource {
135                        value: issue_url,
136                        mime_type: None,
137                        content_version: ContentVersion::Vor,
138                    },
139                },
140            }
141        }
142
143        pub fn add_contributor(&mut self, given_name: String, surname: String, is_first: bool) {
144            let person = PersonName {
145                language: Iso639_1::En,
146                sequence: if is_first {
147                    Sequence::First
148                } else {
149                    Sequence::Additional
150                },
151                contributor_role: ContributorRole::Author,
152                name_style: None,
153                given_name,
154                surname,
155                suffix: None,
156                affiliations: Default::default(),
157                orcid: None,
158            };
159
160            self.contributors.push(Contributor {
161                organization: vec![],
162                person_name: vec![person],
163                anonymous: vec![],
164            });
165        }
166    }
167
168    impl JournalArticle {
169        /// Create a minimal journal article with only required fields
170        pub fn minimal(title: String, year: i32, article_doi: String, article_url: String) -> Self {
171            Self {
172                publication_type: crate::enums::PublicationType::FullText,
173                language: Iso639_1::En,
174                titles: Titles {
175                    title,
176                    subtitle: None,
177                    original_language_title: None,
178                    orginal_language_subtitle: None,
179                },
180                contributors: vec![],
181                jats_abstract: None,
182                publication_date: PublicationDate {
183                    media_type: MediaTypeDate::Online,
184                    month: None,
185                    day: None,
186                    year,
187                },
188                acceptance_date: None,
189                pages: None,
190                publisher_item: None,
191                crossmark: None,
192                archive_locations: ArchiveLocations::default(),
193                doi_data: DoiData {
194                    doi: article_doi,
195                    timestamp: (),
196                    resource: Resource {
197                        value: article_url,
198                        mime_type: None,
199                        content_version: ContentVersion::Vor,
200                    },
201                },
202                citation_list: Default::default(),
203                component_list: Default::default(),
204            }
205        }
206
207        pub fn add_contributor(&mut self, given_name: String, surname: String, is_first: bool) {
208            let person = PersonName {
209                language: Iso639_1::En,
210                sequence: if is_first {
211                    Sequence::First
212                } else {
213                    Sequence::Additional
214                },
215                contributor_role: ContributorRole::Author,
216                name_style: None,
217                given_name,
218                surname,
219                suffix: None,
220                affiliations: Default::default(),
221                orcid: None,
222            };
223
224            self.contributors.push(Contributor {
225                organization: vec![],
226                person_name: vec![person],
227                anonymous: vec![],
228            });
229        }
230
231        pub fn add_component(&mut self, component: Component) {
232            self.component_list.value.push(component);
233        }
234    }
235
236    impl Component {
237        pub fn minimal(
238            title: String,
239            year: i32,
240            component_doi: String,
241            component_url: String,
242        ) -> Self {
243            Self {
244                parent_relation: ParentRelation::default(),
245                language: Iso639_1::En,
246                titles: Some(Titles {
247                    title,
248                    subtitle: None,
249                    original_language_title: None,
250                    orginal_language_subtitle: None,
251                }),
252                contributors: vec![],
253                publication_date: Some(PublicationDate {
254                    media_type: MediaTypeDate::Online,
255                    month: None,
256                    day: None,
257                    year,
258                }),
259                description: None,
260                doi_data: DoiData {
261                    doi: component_doi.clone(),
262                    timestamp: (),
263                    resource: Resource {
264                        value: component_url,
265                        mime_type: None,
266                        content_version: ContentVersion::Vor,
267                    },
268                },
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::enums::{
278        ContentVersion, ContributorRole, Iso639_1, MediaTypeDate, PublicationType, Sequence,
279    };
280    use crate::journal::Journal;
281    use crate::journal::article::{Component, ComponentList, JatsP, JournalArticle};
282    use crate::journal::issue::{
283        Anonymous, Contributor, JournalIssue, PersonName, PublicationDate, Titles,
284    };
285    use crate::journal::metadata::{DoiData, JournalMetadata, Resource};
286    use std::fs;
287    use std::path::Path;
288
289    /// Helper function to validate XML against the Crossref schema using xmllint
290    fn validate_xml_with_schema(xml_path: &Path) {
291        let output = std::process::Command::new("xmllint")
292            .arg("--schema")
293            .arg("schemas/crossref5.4.0.xsd")
294            .arg(xml_path)
295            .arg("--noout")
296            .output();
297
298        match output {
299            Ok(result) => {
300                assert!(
301                    result.status.success(),
302                    "XML validation failed for {:?}:\nstdout: {}\nstderr: {}",
303                    xml_path,
304                    String::from_utf8_lossy(&result.stdout),
305                    String::from_utf8_lossy(&result.stderr)
306                );
307            }
308            Err(e) => {
309                eprintln!(
310                    "Warning: xmllint not available ({}), skipping schema validation",
311                    e
312                );
313            }
314        }
315    }
316
317    #[test]
318    fn test_minimal_journal_validates() {
319        let journal = Journal::minimal(
320            "Test Journal".to_string(),
321            "10.53962/test.journal".to_string(),
322            "https://example.com/journal".to_string(),
323        );
324
325        assert!(journal.validate().is_ok(), "Journal should be valid");
326    }
327
328    #[test]
329    fn test_minimal_journal_to_xml() {
330        let mut batch = DoiBatch::new("Test Publisher".to_string(), "test@example.com".to_string());
331
332        let journal = Journal::minimal(
333            "Test Journal".to_string(),
334            "10.53962/test.journal".to_string(),
335            "https://example.com/journal".to_string(),
336        );
337
338        batch.add_journal(journal);
339
340        assert!(batch.validate().is_ok(), "DoiBatch should be valid");
341
342        let xml = batch.to_xml().expect("Should serialize to XML");
343        assert!(xml.contains("Test Journal"));
344        assert!(xml.contains("10.53962/test.journal"));
345
346        // Write to tests directory
347        let test_dir = Path::new("tests/xml_output");
348        fs::create_dir_all(test_dir).expect("Should create test directory");
349        let xml_path = test_dir.join("minimal_journal.xml");
350        fs::write(&xml_path, &xml).expect("Should write XML file");
351
352        // Validate against schema
353        validate_xml_with_schema(&xml_path);
354    }
355
356    #[test]
357    fn test_minimal_article_validates() {
358        let article = JournalArticle::minimal(
359            "Test Article".to_string(),
360            2024,
361            "10.53962/test.article".to_string(),
362            "https://example.com/article".to_string(),
363        );
364
365        assert!(article.validate().is_ok(), "Article should be valid");
366    }
367
368    #[test]
369    fn test_journal_with_article_to_xml() {
370        let mut batch = DoiBatch::new("Test Publisher".to_string(), "test@example.com".to_string());
371
372        let mut journal = Journal::minimal(
373            "Test Journal".to_string(),
374            "10.53962/test.journal".to_string(),
375            "https://example.com/journal".to_string(),
376        );
377
378        let mut article = JournalArticle::minimal(
379            "Example Research Article".to_string(),
380            2024,
381            "10.53962/test.article.001".to_string(),
382            "https://example.com/article/001".to_string(),
383        );
384
385        article.add_contributor("Jane".to_string(), "Doe".to_string(), true);
386
387        journal.add_article(article);
388        batch.add_journal(journal);
389
390        assert!(
391            batch.validate().is_ok(),
392            "DoiBatch with article should be valid"
393        );
394
395        let xml = batch.to_xml().expect("Should serialize to XML");
396        assert!(xml.contains("Example Research Article"));
397        assert!(xml.contains("Jane"));
398        assert!(xml.contains("Doe"));
399
400        // Write to tests directory
401        let test_dir = Path::new("tests/xml_output");
402        fs::create_dir_all(test_dir).expect("Should create test directory");
403        let xml_path = test_dir.join("journal_with_article.xml");
404        fs::write(&xml_path, &xml).expect("Should write XML file");
405
406        // Validate against schema
407        validate_xml_with_schema(&xml_path);
408    }
409
410    #[test]
411    fn test_minimal_issue_validates() {
412        let issue = JournalIssue::minimal(
413            "Volume 1, Issue 1".to_string(),
414            2024,
415            "10.53962/test.issue.001".to_string(),
416            "https://example.com/issue/001".to_string(),
417        );
418
419        assert!(issue.validate().is_ok(), "Issue should be valid");
420    }
421
422    #[test]
423    fn test_manual_construction() {
424        let batch = DoiBatch {
425            version: (),
426            xmlns: (),
427            xsi_schemalocation: (),
428            xmlns_xsi: (),
429            xmlns_jats: (),
430            head: Head {
431                depositor: Depositor {
432                    depositor_name: "Example Publisher".to_string(),
433                    email_address: "deposits@example.com".to_string(),
434                },
435                registrant: "Example Publisher".to_string(),
436                doi_batch_id: (),
437                timestamp: (),
438            },
439            body: Body {
440                journal: vec![Journal {
441                    journal_metadata: JournalMetadata {
442                        lang: Iso639_1::En,
443                        full_title: "Journal of Example Research".to_string(),
444                        abbrev_title: Some("J. Ex. Res.".to_string()),
445                        issn: None,
446                        coden: None,
447                        archive_locations: Default::default(),
448                        doi_data: DoiData {
449                            doi: "10.1234/journal.example".to_string(),
450                            timestamp: (),
451                            resource: Resource {
452                                value: "https://example.com/journal".to_string(),
453                                mime_type: None,
454                                content_version: ContentVersion::Vor,
455                            },
456                        },
457                    },
458                    journal_issue: None,
459                    journal_article: vec![JournalArticle {
460                        publication_type: PublicationType::FullText,
461                        language: Iso639_1::En,
462                        titles: Titles {
463                            title: "A Comprehensive Study of Example Phenomena".to_string(),
464                            subtitle: Some("Methods and Applications".to_string()),
465                            original_language_title: None,
466                            orginal_language_subtitle: None,
467                        },
468                        contributors: vec![Contributor {
469                            organization: vec![],
470                            person_name: vec![
471                                PersonName {
472                                    language: Iso639_1::En,
473                                    sequence: Sequence::First,
474                                    contributor_role: ContributorRole::Author,
475                                    name_style: None,
476                                    given_name: "Jane".to_string(),
477                                    surname: "Smith".to_string(),
478                                    suffix: None,
479                                    affiliations: Default::default(),
480                                    orcid: None,
481                                },
482                                PersonName {
483                                    language: Iso639_1::En,
484                                    sequence: Sequence::First,
485                                    contributor_role: ContributorRole::Author,
486                                    name_style: None,
487                                    given_name: "Jane".to_string(),
488                                    surname: "Smith".to_string(),
489                                    suffix: None,
490                                    affiliations: Default::default(),
491                                    orcid: None,
492                                },
493                            ],
494                            anonymous: vec![Anonymous {
495                                language: Iso639_1::En,
496                                sequence: Sequence::Additional,
497                                contributor_role: ContributorRole::Author,
498                                name_style: None,
499                            }],
500                        }],
501                        jats_abstract: Some(JatsP {
502                            value: "This article presents a comprehensive study of example phenomena \
503                            in various contexts. We demonstrate novel methods and their applications."
504                                .to_string(),
505                        }),
506                        publication_date: PublicationDate {
507                            media_type: MediaTypeDate::Online,
508                            month: Some(12),
509                            day: Some(23),
510                            year: 2025,
511                        },
512                        acceptance_date: None,
513                        pages: None,
514                        publisher_item: None,
515                        crossmark: None,
516                        archive_locations: Default::default(),
517                        doi_data: DoiData {
518                            doi: "10.1234/journal.example.2025.001".to_string(),
519                            timestamp: (),
520                            resource: Resource {
521                                value: "https://example.com/article/2025/001".to_string(),
522                                mime_type: Some("text/html".to_string()),
523                                content_version: ContentVersion::Vor,
524                            },
525                        },
526                        citation_list: Default::default(),
527                        component_list: ComponentList {
528                            value: vec![Component {
529                                parent_relation: Default::default(),
530                                language: Default::default(),
531                                titles: Some(Titles {
532                                    title: "A Comprehensive Study of Example Phenomena".to_string(),
533                                    subtitle: Some("Methods and Applications".to_string()),
534                                    original_language_title: None,
535                                    orginal_language_subtitle: None,
536                                }),
537                                contributors: vec![],
538                                publication_date: Some(PublicationDate {
539                                    media_type: MediaTypeDate::Online,
540                                    month: Some(12),
541                                    day: Some(23),
542                                    year: 2025,
543                                }),
544                                description: None,
545                                doi_data: DoiData {
546                                    doi: "10.1234/journal.example.2025.001".to_string(),
547                                    timestamp: (),
548                                    resource: Resource {
549                                        value: "https://example.com/article/2025/0011234".to_string(),
550                                        mime_type: Some("text/html".to_string()),
551                                        content_version: ContentVersion::Vor,
552                                    },
553                                },
554                            }]
555                        },
556                    }],
557                }],
558            },
559        };
560
561        let xml = batch.to_xml().expect("Should serialize to XML");
562
563        // Write to tests directory
564        let test_dir = Path::new("tests/xml_output");
565        fs::create_dir_all(test_dir).expect("Should create test directory");
566        let xml_path = test_dir.join("manual_construct.xml");
567        fs::write(&xml_path, &xml).expect("Should write XML file");
568
569        // Validate against schema
570        validate_xml_with_schema(&xml_path);
571    }
572}