1use crate::{
2 enums::{IdType, Iso639_1, ParentRelation, PublicationType, UpdateType},
3 journal::{
4 issue::{Contributor, PublicationDate, Titles},
5 metadata::{ArchiveLocations, DoiData},
6 },
7 regex::DOI_REGEX,
8};
9use chrono::NaiveDate;
10use serde::Serialize;
11use validator::Validate;
12
13#[derive(Debug, Clone, Default, Serialize, Validate)]
14pub struct JournalArticle {
15 #[serde(rename = "@publication_type")]
16 pub publication_type: PublicationType,
17 #[serde(rename = "@language")]
18 pub language: Iso639_1,
19 #[validate(nested)]
20 pub titles: Titles,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
22 #[validate(nested)]
23 pub contributors: Vec<Contributor>,
24 #[serde(rename = "jats:abstract")]
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub jats_abstract: Option<JatsP>,
28 #[validate(nested)]
29 pub publication_date: PublicationDate,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 #[validate(nested)]
33 pub acceptance_date: Option<PublicationDate>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 #[validate(nested)]
36 pub pages: Option<Pages>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 #[validate(nested)]
39 pub publisher_item: Option<PublisherItem>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 #[validate(nested)]
42 pub crossmark: Option<Crossmark>,
43 #[serde(skip_serializing_if = "ArchiveLocations::is_empty")]
52 #[validate(nested)]
53 pub archive_locations: ArchiveLocations,
54 #[validate(nested)]
59 pub doi_data: DoiData,
60 #[serde(skip_serializing_if = "CitationList::is_empty")]
61 #[validate(nested)]
62 pub citation_list: CitationList,
63 #[serde(skip_serializing_if = "ComponentList::is_empty")]
64 #[validate(nested)]
65 pub component_list: ComponentList,
66}
67
68#[derive(Debug, Clone, Default, Serialize, Validate)]
69pub struct JatsP {
70 #[serde(rename = "jats:p")]
71 pub value: String,
74}
75
76#[derive(Debug, Clone, Default, Serialize, Validate)]
77pub struct Pages {
78 #[validate(length(min = 1, max = 32))]
79 pub first_page: String,
80 #[validate(length(min = 1, max = 32))]
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub last_page: Option<String>,
83 #[validate(length(min = 1, max = 100))]
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub other_pages: Option<String>,
86}
87
88#[derive(Debug, Clone, Default, Serialize, Validate)]
89pub struct PublisherItem {
90 #[serde(skip_serializing_if = "Vec::is_empty")]
91 #[validate(length(max = 3))]
92 pub item_number: Vec<ItemNumberT>,
93 #[serde(skip_serializing_if = "Vec::is_empty")]
94 #[validate(length(max = 10))]
95 pub identifier: Vec<IdentifierT>,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Validate)]
99pub struct ItemNumberT {
100 #[validate(length(min = 1, max = 32))]
101 pub value: String,
102}
103
104#[derive(Debug, Clone, Serialize, Validate)]
105pub struct IdentifierT {
106 #[serde(rename = "@id_type")]
107 pub id_type: IdType,
108 #[validate(length(min = 1, max = 255))]
109 #[serde(rename = "$text")]
110 pub value: String,
111}
112
113#[derive(Debug, Clone, Default, Serialize, Validate)]
114pub struct Crossmark {
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub crossmark_version: Option<String>,
117 #[validate(length(min = 6, max = 2048), regex(path = *DOI_REGEX))]
118 pub crossmark_policy: String,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub crossmark_domain_exclusive: Option<bool>,
122 #[serde(skip_serializing_if = "Updates::is_empty")]
123 #[validate(nested)]
124 pub updates: Updates,
125 }
127
128#[derive(Debug, Clone, Default, Serialize, Validate)]
129pub struct Updates {
130 #[validate(length(min = 1))]
131 pub update: Vec<Update>,
132}
133
134impl Updates {
135 pub fn is_empty(&self) -> bool {
136 self.update.is_empty()
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Validate)]
141pub struct Update {
142 #[serde(rename = "@type")]
143 pub update_type: UpdateType,
144 #[serde(rename = "@date")]
145 pub update_date: NaiveDate,
146 #[validate(length(min = 6, max = 2048), regex(path = *DOI_REGEX))]
147 #[serde(rename = "$text")]
148 pub value: String,
149}
150
151#[derive(Debug, Clone, Default, Serialize, Validate)]
152pub struct CitationList {
153 #[serde(rename = "$text")]
154 pub value: Vec<Citation>,
155}
156
157impl CitationList {
158 pub fn is_empty(&self) -> bool {
159 self.value.is_empty()
160 }
161}
162
163#[derive(Debug, Clone, Default, Serialize, Validate)]
164pub struct Citation {
165 #[serde(skip_serializing_if = "Option::is_none")]
166 #[validate(length(min = 6, max = 2048), regex(path = *DOI_REGEX))]
167 pub doi: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub elocation_id: Option<String>,
170}
171
172#[derive(Debug, Clone, Default, Serialize, Validate)]
173pub struct ComponentList {
174 #[serde(rename = "component")]
175 pub value: Vec<Component>,
176}
177
178impl ComponentList {
179 pub fn is_empty(&self) -> bool {
180 self.value.is_empty()
181 }
182}
183
184#[derive(Debug, Clone, Default, Serialize, Validate)]
185#[serde(rename = "component")]
186pub struct Component {
187 #[serde(rename = "@parent_relation")]
188 pub parent_relation: ParentRelation,
189 #[serde(rename = "@language")]
191 pub language: Iso639_1,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 #[validate(nested)]
195 pub titles: Option<Titles>,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 #[validate(nested)]
198 pub contributors: Vec<Contributor>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 #[validate(nested)]
201 pub publication_date: Option<PublicationDate>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 #[validate(nested)]
204 pub description: Option<Description>,
205 #[validate(nested)]
208 pub doi_data: DoiData,
209 }
212
213#[derive(Debug, Clone, Default, Serialize, Validate)]
214pub struct Description {
215 #[serde(rename = "@language")]
216 pub language: Iso639_1,
217 #[serde(rename = "$text")]
218 pub value: String,
219}
220
221#[cfg(test)]
222mod unit {
223 use super::*;
224
225 mod description {
226 use super::*;
227
228 fn valid_description() -> Description {
229 Description {
230 language: Iso639_1::En,
231 value: "Example description".to_string(),
232 }
233 }
234
235 #[test]
236 fn valid_description_passes() {
237 let desc = valid_description();
238 assert!(desc.validate().is_ok());
239 }
240 }
241
242 mod component {
243 use super::*;
244 use crate::enums::ContentVersion;
245 use crate::journal::metadata::Resource;
246
247 fn valid_component() -> Component {
248 Component {
249 parent_relation: ParentRelation::IsPartOf,
250 language: Iso639_1::En,
251 titles: None,
252 contributors: vec![],
253 publication_date: None,
254 description: None,
255 doi_data: DoiData {
256 doi: "10.1234/component.001".to_string(),
257 timestamp: (),
258 resource: Resource {
259 value: "https://example.com/component".to_string(),
260 mime_type: None,
261 content_version: ContentVersion::Vor,
262 },
263 },
264 }
265 }
266
267 #[test]
268 fn valid_component_passes() {
269 let comp = valid_component();
270 assert!(comp.validate().is_ok());
271 }
272
273 #[test]
274 fn with_description_passes() {
275 let mut comp = valid_component();
276 comp.description = Some(Description {
277 language: Iso639_1::En,
278 value: "Component description".to_string(),
279 });
280 assert!(comp.validate().is_ok());
281 }
282
283 #[test]
284 fn invalid_doi_data_fails() {
285 let mut comp = valid_component();
286 comp.doi_data.doi = "invalid".to_string();
287 assert!(comp.validate().is_err());
288 }
289 }
290
291 mod component_list {
292 use super::*;
293 use crate::enums::ContentVersion;
294 use crate::journal::metadata::Resource;
295
296 fn valid_component() -> Component {
297 Component {
298 parent_relation: ParentRelation::IsPartOf,
299 language: Iso639_1::En,
300 titles: None,
301 contributors: vec![Contributor {
302 organization: vec![],
303 person_name: vec![],
304 anonymous: vec![],
305 }],
306 publication_date: None,
307 description: None,
308 doi_data: DoiData {
309 doi: "10.1234/component".to_string(),
310 timestamp: (),
311 resource: Resource {
312 value: "https://example.com/component".to_string(),
313 mime_type: None,
314 content_version: ContentVersion::Vor,
315 },
316 },
317 }
318 }
319
320 #[test]
321 fn empty_component_list_passes() {
322 let list = ComponentList::default();
323 assert!(list.validate().is_ok());
324 assert!(list.is_empty());
325 }
326
327 #[test]
328 fn component_list_with_components_passes() {
329 let list = ComponentList {
330 value: vec![valid_component()],
331 };
332 assert!(list.validate().is_ok());
333 assert!(!list.is_empty());
334 }
335
336 #[test]
337 fn multiple_components_pass() {
338 let list = ComponentList {
339 value: vec![valid_component(), valid_component()],
340 };
341 assert!(list.validate().is_ok());
342 }
343 }
344
345 mod citation {
346 use super::*;
347
348 #[test]
349 fn citation_with_doi_passes() {
350 let citation = Citation {
351 doi: Some("10.1234/citation.001".to_string()),
352 elocation_id: None,
353 };
354 assert!(citation.validate().is_ok());
355 }
356
357 #[test]
358 fn citation_with_elocation_id_passes() {
359 let citation = Citation {
360 doi: None,
361 elocation_id: Some("e123456".to_string()),
362 };
363 assert!(citation.validate().is_ok());
364 }
365
366 #[test]
367 fn citation_with_both_passes() {
368 let citation = Citation {
369 doi: Some("10.1234/citation.001".to_string()),
370 elocation_id: Some("e123456".to_string()),
371 };
372 assert!(citation.validate().is_ok());
373 }
374
375 #[test]
376 fn citation_with_invalid_doi_fails() {
377 let citation = Citation {
378 doi: Some("invalid".to_string()),
379 elocation_id: None,
380 };
381 assert!(citation.validate().is_err());
382 }
383
384 #[test]
385 fn empty_citation_passes() {
386 let citation = Citation::default();
387 assert!(citation.validate().is_ok());
388 }
389 }
390
391 mod citation_list {
392 use super::*;
393
394 #[test]
395 fn empty_citation_list_passes() {
396 let list = CitationList::default();
397 assert!(list.validate().is_ok());
398 assert!(list.is_empty());
399 }
400
401 #[test]
402 fn citation_list_with_citations_passes() {
403 let list = CitationList {
404 value: vec![Citation {
405 doi: Some("10.1234/ref.001".to_string()),
406 elocation_id: None,
407 }],
408 };
409 assert!(list.validate().is_ok());
410 assert!(!list.is_empty());
411 }
412
413 #[test]
414 fn multiple_citations_pass() {
415 let list = CitationList {
416 value: vec![
417 Citation {
418 doi: Some("10.1234/ref.001".to_string()),
419 elocation_id: None,
420 },
421 Citation {
422 doi: Some("10.1234/ref.002".to_string()),
423 elocation_id: Some("e123".to_string()),
424 },
425 ],
426 };
427 assert!(list.validate().is_ok());
428 }
429 }
430
431 mod update {
432 use super::*;
433 use chrono::NaiveDate;
434
435 fn valid_update() -> Update {
436 Update {
437 update_type: UpdateType::Correction,
438 update_date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
439 value: "10.1234/update.001".to_string(),
440 }
441 }
442
443 #[test]
444 fn valid_update_passes() {
445 let update = valid_update();
446 assert!(update.validate().is_ok());
447 }
448
449 #[test]
450 fn value_invalid_doi_fails() {
451 let mut update = valid_update();
452 update.value = "invalid".to_string();
453 assert!(update.validate().is_err());
454 }
455
456 #[test]
457 fn various_update_types_pass() {
458 let types = [
459 UpdateType::Addendum,
460 UpdateType::Correction,
461 UpdateType::Corrigendum,
462 UpdateType::Erratum,
463 UpdateType::ExpressionOfConcern,
464 UpdateType::NewEdition,
465 UpdateType::NewVersion,
466 UpdateType::PartialRetraction,
467 UpdateType::Removal,
468 UpdateType::Retraction,
469 UpdateType::Withdrawal,
470 ];
471 for update_type in types {
472 let mut update = valid_update();
473 update.update_type = update_type;
474 assert!(update.validate().is_ok());
475 }
476 }
477 }
478
479 mod updates {
480 use super::*;
481 use chrono::NaiveDate;
482
483 fn valid_update() -> Update {
484 Update {
485 update_type: UpdateType::Correction,
486 update_date: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
487 value: "10.1234/update.001".to_string(),
488 }
489 }
490
491 #[test]
492 fn updates_with_one_update_passes() {
493 let updates = Updates {
494 update: vec![valid_update()],
495 };
496 assert!(updates.validate().is_ok());
497 assert!(!updates.is_empty());
498 }
499
500 #[test]
501 fn updates_with_multiple_updates_passes() {
502 let updates = Updates {
503 update: vec![valid_update(), valid_update()],
504 };
505 assert!(updates.validate().is_ok());
506 }
507
508 #[test]
509 fn empty_updates_fails() {
510 let updates = Updates { update: vec![] };
511 assert!(updates.validate().is_err());
512 }
513 }
514
515 mod crossmark {
516 use super::*;
517
518 fn valid_crossmark() -> Crossmark {
519 Crossmark {
520 crossmark_version: Some("1".to_string()),
521 crossmark_policy: "10.1234/policy".to_string(),
522 crossmark_domain_exclusive: Some(true),
523 updates: Updates::default(),
524 }
525 }
526
527 #[test]
528 fn valid_crossmark_passes() {
529 let mut crossmark = valid_crossmark();
530 crossmark.updates = Updates {
532 update: vec![Update {
533 update_type: UpdateType::Correction,
534 update_date: chrono::NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
535 value: "10.1234/update".to_string(),
536 }],
537 };
538 assert!(crossmark.validate().is_ok());
539 }
540
541 #[test]
542 fn crossmark_policy_invalid_doi_fails() {
543 let mut crossmark = valid_crossmark();
544 crossmark.crossmark_policy = "invalid".to_string();
545 assert!(crossmark.validate().is_err());
546 }
547 }
548
549 mod identifier_t {
550 use super::*;
551
552 fn valid_identifier() -> IdentifierT {
553 IdentifierT {
554 id_type: IdType::Pii,
555 value: "ABC123".to_string(),
556 }
557 }
558
559 #[test]
560 fn valid_identifier_passes() {
561 let id = valid_identifier();
562 assert!(id.validate().is_ok());
563 }
564
565 #[test]
566 fn various_id_types_pass() {
567 let types = [
568 IdType::Dai,
569 IdType::StdDesignation,
570 IdType::Doi,
571 IdType::Pii,
572 IdType::Pmid,
573 IdType::Sici,
574 ];
575 for id_type in types {
576 let mut id = valid_identifier();
577 id.id_type = id_type;
578 assert!(id.validate().is_ok());
579 }
580 }
581 }
582
583 mod item_number_t {
584 use super::*;
585
586 fn valid_item_number() -> ItemNumberT {
587 ItemNumberT {
588 value: "ITEM-001".to_string(),
589 }
590 }
591
592 #[test]
593 fn valid_item_number_passes() {
594 let item = valid_item_number();
595 assert!(item.validate().is_ok());
596 }
597 }
598
599 mod publisher_item {
600 use super::*;
601
602 fn valid_publisher_item() -> PublisherItem {
603 PublisherItem {
604 item_number: vec![ItemNumberT {
605 value: "ITEM-001".to_string(),
606 }],
607 identifier: vec![IdentifierT {
608 id_type: IdType::Pii,
609 value: "ABC123".to_string(),
610 }],
611 }
612 }
613
614 #[test]
615 fn valid_publisher_item_passes() {
616 let item = valid_publisher_item();
617 assert!(item.validate().is_ok());
618 }
619
620 #[test]
621 fn empty_publisher_item_passes() {
622 let item = PublisherItem::default();
623 assert!(item.validate().is_ok());
624 }
625 }
626
627 mod pages {
628 use super::*;
629
630 fn valid_pages() -> Pages {
631 Pages {
632 first_page: "1".to_string(),
633 last_page: Some("10".to_string()),
634 other_pages: None,
635 }
636 }
637
638 #[test]
639 fn valid_pages_passes() {
640 let pages = valid_pages();
641 assert!(pages.validate().is_ok());
642 }
643
644 #[test]
645 fn last_page_none_passes() {
646 let mut pages = valid_pages();
647 pages.last_page = None;
648 assert!(pages.validate().is_ok());
649 }
650
651 #[test]
652 fn other_pages_none_passes() {
653 let mut pages = valid_pages();
654 pages.other_pages = None;
655 assert!(pages.validate().is_ok());
656 }
657
658 #[test]
659 fn only_first_page_passes() {
660 let pages = Pages {
661 first_page: "1".to_string(),
662 last_page: None,
663 other_pages: None,
664 };
665 assert!(pages.validate().is_ok());
666 }
667 }
668
669 mod journal_article {
670 use super::*;
671 use crate::enums::{ContentVersion, MediaTypeDate};
672 use crate::journal::metadata::Resource;
673
674 fn valid_journal_article() -> JournalArticle {
675 JournalArticle {
676 publication_type: PublicationType::FullText,
677 language: Iso639_1::En,
678 titles: Titles {
679 title: "Example Article".to_string(),
680 subtitle: None,
681 original_language_title: None,
682 orginal_language_subtitle: None,
683 },
684 contributors: vec![],
685 jats_abstract: Some(JatsP {
686 value: "Abstract text".to_string(),
687 }),
688 publication_date: PublicationDate {
689 media_type: MediaTypeDate::Online,
690 month: Some(6),
691 day: Some(15),
692 year: 2024,
693 },
694 acceptance_date: None,
695 pages: None,
696 publisher_item: None,
697 crossmark: None,
698 archive_locations: ArchiveLocations::default(),
699 doi_data: DoiData {
700 doi: "10.1234/article.001".to_string(),
701 timestamp: (),
702 resource: Resource {
703 value: "https://example.com/article".to_string(),
704 mime_type: None,
705 content_version: ContentVersion::Vor,
706 },
707 },
708 citation_list: CitationList::default(),
709 component_list: ComponentList::default(),
710 }
711 }
712
713 #[test]
714 fn valid_journal_article_passes() {
715 let article = valid_journal_article();
716 assert!(article.validate().is_ok());
717 }
718
719 #[test]
720 fn with_pages_passes() {
721 let mut article = valid_journal_article();
722 article.pages = Some(Pages {
723 first_page: "1".to_string(),
724 last_page: Some("10".to_string()),
725 other_pages: None,
726 });
727 assert!(article.validate().is_ok());
728 }
729
730 #[test]
731 fn with_invalid_pages_fails() {
732 let mut article = valid_journal_article();
733 article.pages = Some(Pages {
734 first_page: "".to_string(),
735 last_page: None,
736 other_pages: None,
737 });
738 assert!(article.validate().is_err());
739 }
740
741 #[test]
742 fn with_publisher_item_passes() {
743 let mut article = valid_journal_article();
744 article.publisher_item = Some(PublisherItem {
745 item_number: vec![ItemNumberT {
746 value: "ITEM-001".to_string(),
747 }],
748 identifier: vec![],
749 });
750 assert!(article.validate().is_ok());
751 }
752
753 #[test]
754 fn with_acceptance_date_passes() {
755 let mut article = valid_journal_article();
756 article.acceptance_date = Some(PublicationDate {
757 media_type: MediaTypeDate::Online,
758 month: Some(5),
759 day: Some(1),
760 year: 2024,
761 });
762 assert!(article.validate().is_ok());
763 }
764
765 #[test]
766 fn with_invalid_acceptance_date_fails() {
767 let mut article = valid_journal_article();
768 article.acceptance_date = Some(PublicationDate {
769 media_type: MediaTypeDate::Online,
770 month: None,
771 day: None,
772 year: 1000,
773 });
774 assert!(article.validate().is_err());
775 }
776
777 #[test]
778 fn with_citations_passes() {
779 let mut article = valid_journal_article();
780 article.citation_list = CitationList {
781 value: vec![Citation {
782 doi: Some("10.1234/ref.001".to_string()),
783 elocation_id: None,
784 }],
785 };
786 assert!(article.validate().is_ok());
787 }
788
789 #[test]
790 fn with_components_passes() {
791 let mut article = valid_journal_article();
792 article.component_list = ComponentList {
793 value: vec![Component {
794 parent_relation: ParentRelation::IsPartOf,
795 language: Iso639_1::En,
796 titles: None,
797 contributors: vec![],
798 publication_date: None,
799 description: None,
800 doi_data: DoiData {
801 doi: "10.1234/comp".to_string(),
802 timestamp: (),
803 resource: Resource {
804 value: "https://example.com/comp".to_string(),
805 mime_type: None,
806 content_version: ContentVersion::Vor,
807 },
808 },
809 }],
810 };
811 assert!(article.validate().is_ok());
812 }
813
814 #[test]
815 fn minimal_article_passes() {
816 let article = JournalArticle {
817 publication_type: PublicationType::FullText,
818 language: Iso639_1::En,
819 titles: Titles {
820 title: "T".to_string(),
821 subtitle: None,
822 original_language_title: None,
823 orginal_language_subtitle: None,
824 },
825 contributors: vec![],
826 jats_abstract: None,
827 publication_date: PublicationDate {
828 media_type: MediaTypeDate::Online,
829 month: None,
830 day: None,
831 year: 2024,
832 },
833 acceptance_date: None,
834 pages: None,
835 publisher_item: None,
836 crossmark: None,
837 archive_locations: ArchiveLocations::default(),
838 doi_data: DoiData {
839 doi: "10.1234/a".to_string(),
840 timestamp: (),
841 resource: Resource {
842 value: "https://example.com".to_string(),
843 mime_type: None,
844 content_version: ContentVersion::Vor,
845 },
846 },
847 citation_list: CitationList::default(),
848 component_list: ComponentList::default(),
849 };
850 assert!(article.validate().is_ok());
851 }
852 }
853}