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 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 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 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 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 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 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_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 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_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 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_xml_with_schema(&xml_path);
571 }
572}