Skip to main content

crossref_xml/journal/
issue.rs

1use crate::{
2    enums::{ContributorRole, InstitutionIdType, Iso639_1, MediaTypeDate, NameStyle, Sequence},
3    journal::metadata::{ArchiveLocations, DoiData},
4    regex::{INSTITUTION_PID_REGEX, NAME_REGEX, ORCID_REGEX},
5    serializers::*,
6};
7use serde::Serialize;
8use validator::Validate;
9
10#[derive(Debug, Clone, Default, Serialize, Validate)]
11pub struct JournalIssue {
12    #[serde(default, skip_serializing_if = "Vec::is_empty")]
13    #[validate(nested)]
14    pub contributors: Vec<Contributor>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    #[validate(nested)]
17    pub titles: Option<Titles>,
18    #[validate(nested)]
19    pub publication_date: PublicationDate,
20    // TODO: journal_volume
21    #[serde(skip_serializing_if = "Option::is_none")]
22    #[validate(length(min = 1, max = 32))]
23    pub issue: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    #[validate(length(min = 1, max = 15))]
26    pub special_numbering: Option<String>,
27    #[validate(nested)]
28    pub archive_locations: ArchiveLocations,
29    #[validate(nested)]
30    pub doi_data: DoiData,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Validate)]
34pub struct PublicationDate {
35    #[serde(rename = "@media_type")]
36    pub media_type: MediaTypeDate,
37    #[serde(serialize_with = "serialize_two_digits")]
38    #[serde(skip_serializing_if = "Option::is_none")]
39    #[validate(range(min = 1, max = 34))]
40    pub month: Option<u32>,
41    #[serde(serialize_with = "serialize_two_digits")]
42    #[serde(skip_serializing_if = "Option::is_none")]
43    #[validate(range(min = 1, max = 31))]
44    pub day: Option<u32>,
45    #[validate(range(min = 1400, max = 2200))]
46    pub year: i32,
47}
48
49#[derive(Debug, Clone, Default, Serialize, Validate)]
50pub struct Titles {
51    pub title: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub subtitle: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    #[validate(nested)]
56    pub original_language_title: Option<OriginalLanguageTitle>,
57    #[serde(rename = "subtitle")]
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub orginal_language_subtitle: Option<String>,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Validate)]
63pub struct OriginalLanguageTitle {
64    #[serde(rename = "@language")]
65    pub language: Iso639_1,
66    #[serde(rename = "$text")]
67    pub value: String,
68}
69
70// TODO: Validate at least one is present
71// #[derive(Debug, Clone, Default, Serialize, Validate)]
72// pub struct Contributors {
73//     #[serde(skip_serializing_if = "Option::is_none")]
74//     #[validate(nested)]
75//     pub organization: Option<Organization>,
76//     #[serde(skip_serializing_if = "Option::is_none")]
77//     #[validate(nested)]
78//     pub person_name: Option<PersonName>,
79//     #[serde(skip_serializing_if = "Option::is_none")]
80//     #[validate(nested)]
81//     pub anonymous: Option<Anonymous>,
82// }
83
84#[derive(Debug, Clone, Default, Serialize, Validate)]
85pub struct Contributors {
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    #[serde(rename = "$text")]
88    pub value: Vec<Contributor>,
89}
90
91#[derive(Debug, Clone, Default, Serialize, Validate)]
92pub struct Contributor {
93    #[serde(skip_serializing_if = "Vec::is_empty")]
94    #[validate(nested)]
95    pub organization: Vec<Organization>,
96    #[serde(skip_serializing_if = "Vec::is_empty")]
97    #[validate(nested)]
98    pub person_name: Vec<PersonName>,
99    #[serde(skip_serializing_if = "Vec::is_empty")]
100    #[validate(nested)]
101    pub anonymous: Vec<Anonymous>,
102}
103
104#[derive(Debug, Clone, Default, Serialize, Validate)]
105pub struct Organization {
106    #[serde(rename = "$text")]
107    pub value: String,
108    #[serde(rename = "@language")]
109    pub language: Iso639_1,
110    #[serde(rename = "@sequence")]
111    pub sequence: Sequence,
112    #[serde(rename = "@contributor_role")]
113    pub contributor_role: ContributorRole,
114}
115
116#[derive(Debug, Clone, Default, Serialize, Validate)]
117pub struct Anonymous {
118    #[serde(rename = "@language")]
119    pub language: Iso639_1,
120    #[serde(rename = "@sequence")]
121    pub sequence: Sequence,
122    #[serde(rename = "@contributor_role")]
123    pub contributor_role: ContributorRole,
124    #[serde(rename = "@name-style")]
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub name_style: Option<NameStyle>,
127}
128
129#[derive(Debug, Clone, Default, Serialize, Validate)]
130pub struct PersonName {
131    #[serde(rename = "@language")]
132    pub language: Iso639_1,
133    #[serde(rename = "@sequence")]
134    pub sequence: Sequence,
135    #[serde(rename = "@contributor_role")]
136    pub contributor_role: ContributorRole,
137    #[serde(rename = "@name-style")]
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub name_style: Option<NameStyle>,
140    #[validate(length(min = 1, max = 60))]
141    #[validate(regex(path = *NAME_REGEX))]
142    pub given_name: String,
143    #[validate(length(min = 1, max = 60))]
144    #[validate(regex(path = *NAME_REGEX))]
145    pub surname: String,
146    #[validate(length(min = 1, max = 10))]
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub suffix: Option<String>,
149    #[serde(skip_serializing_if = "Affiliations::is_empty")]
150    #[validate(nested)]
151    pub affiliations: Affiliations,
152    #[serde(rename = "ORCID")]
153    #[serde(skip_serializing_if = "Option::is_none")]
154    #[validate(nested)]
155    pub orcid: Option<Orcid>,
156    // pub TODO: alt-name
157}
158
159impl Affiliations {
160    pub fn is_empty(&self) -> bool {
161        self.institution.is_empty()
162    }
163}
164
165#[derive(Debug, Clone, Default, Serialize, Validate)]
166pub struct Orcid {
167    #[serde(rename = "@authenticated")]
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub authenticated: Option<bool>,
170    #[serde(rename = "$text")]
171    #[validate(regex(path = *ORCID_REGEX))]
172    pub value: String,
173}
174
175#[derive(Debug, Clone, Default, Serialize, Validate)]
176pub struct Affiliations {
177    #[validate(nested)]
178    pub institution: Vec<Institution>,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Validate)]
182pub struct Institution {
183    #[validate(nested)]
184    pub institution_id: InstitutionId,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Validate)]
188pub struct InstitutionId {
189    #[serde(rename = "@type")]
190    pub institution_type: InstitutionIdType,
191    #[serde(rename = "$text")]
192    #[validate(regex(path = *INSTITUTION_PID_REGEX))]
193    pub value: String,
194}
195
196#[cfg(test)]
197mod unit {
198    use super::*;
199
200    mod institution_id {
201        use super::*;
202
203        fn valid_institution_id() -> InstitutionId {
204            InstitutionId {
205                institution_type: InstitutionIdType::Ror,
206                value: "https://ror.org/12345".to_string(),
207            }
208        }
209
210        #[test]
211        fn valid_institution_id_passes() {
212            let inst_id = valid_institution_id();
213            assert!(inst_id.validate().is_ok());
214        }
215
216        #[test]
217        fn invalid_url_fails() {
218            let mut inst_id = valid_institution_id();
219            inst_id.value = "http://example.com".to_string(); // http not https
220            assert!(inst_id.validate().is_err());
221        }
222
223        #[test]
224        fn various_institution_types_pass() {
225            let types = [
226                InstitutionIdType::Ror,
227                InstitutionIdType::Isni,
228                InstitutionIdType::Wikidata,
229            ];
230            for inst_type in types {
231                let mut inst_id = valid_institution_id();
232                inst_id.institution_type = inst_type;
233                assert!(inst_id.validate().is_ok());
234            }
235        }
236    }
237
238    mod institution {
239        use super::*;
240
241        fn valid_institution() -> Institution {
242            Institution {
243                institution_id: InstitutionId {
244                    institution_type: InstitutionIdType::Ror,
245                    value: "https://ror.org/12345".to_string(),
246                },
247            }
248        }
249
250        #[test]
251        fn valid_institution_passes() {
252            let inst = valid_institution();
253            assert!(inst.validate().is_ok());
254        }
255    }
256
257    mod affiliations {
258        use super::*;
259
260        fn valid_affiliation() -> Affiliations {
261            Affiliations {
262                institution: vec![Institution {
263                    institution_id: InstitutionId {
264                        institution_type: InstitutionIdType::Ror,
265                        value: "https://ror.org/12345".to_string(),
266                    },
267                }],
268            }
269        }
270
271        #[test]
272        fn valid_affiliations_passes() {
273            let aff = valid_affiliation();
274            assert!(aff.validate().is_ok());
275            assert!(!aff.is_empty());
276        }
277
278        #[test]
279        fn empty_affiliations_passes() {
280            let aff = Affiliations::default();
281            assert!(aff.validate().is_ok());
282            assert!(aff.is_empty());
283        }
284
285        #[test]
286        fn multiple_institutions_pass() {
287            let aff = Affiliations {
288                institution: vec![
289                    Institution {
290                        institution_id: InstitutionId {
291                            institution_type: InstitutionIdType::Ror,
292                            value: "https://ror.org/12345".to_string(),
293                        },
294                    },
295                    Institution {
296                        institution_id: InstitutionId {
297                            institution_type: InstitutionIdType::Isni,
298                            value: "https://isni.org/abc".to_string(),
299                        },
300                    },
301                ],
302            };
303            assert!(aff.validate().is_ok());
304        }
305    }
306
307    mod orcid {
308        use super::*;
309
310        fn valid_orcid() -> Orcid {
311            Orcid {
312                authenticated: Some(true),
313                value: "https://orcid.org/0000-0002-1825-0097".to_string(),
314            }
315        }
316
317        #[test]
318        fn valid_orcid_passes() {
319            let orcid = valid_orcid();
320            assert!(orcid.validate().is_ok());
321        }
322
323        #[test]
324        fn invalid_orcid_format_fails() {
325            let mut orcid = valid_orcid();
326            orcid.value = "invalid-orcid".to_string();
327            assert!(orcid.validate().is_err());
328        }
329
330        #[test]
331        fn orcid_without_protocol_fails() {
332            let mut orcid = valid_orcid();
333            orcid.value = "orcid.org/0000-0002-1825-0097".to_string();
334            assert!(orcid.validate().is_err());
335        }
336
337        #[test]
338        fn authenticated_false_passes() {
339            let orcid = Orcid {
340                authenticated: Some(false),
341                value: "https://orcid.org/0000-0002-1825-0097".to_string(),
342            };
343            assert!(orcid.validate().is_ok());
344        }
345
346        #[test]
347        fn authenticated_none_passes() {
348            let orcid = Orcid {
349                authenticated: None,
350                value: "https://orcid.org/0000-0002-1825-0097".to_string(),
351            };
352            assert!(orcid.validate().is_ok());
353        }
354
355        #[test]
356        fn various_valid_orcids_pass() {
357            let orcids = [
358                "https://orcid.org/0000-0002-1825-0097",
359                "http://orcid.org/0000-0001-5109-3700",
360                "https://orcid.org/0000-0002-1694-233X",
361            ];
362            for orcid_str in orcids {
363                let orcid = Orcid {
364                    authenticated: None,
365                    value: orcid_str.to_string(),
366                };
367                assert!(
368                    orcid.validate().is_ok(),
369                    "ORCID should be valid: {}",
370                    orcid_str
371                );
372            }
373        }
374    }
375
376    mod person_name {
377        use super::*;
378
379        fn valid_person_name() -> PersonName {
380            PersonName {
381                language: Iso639_1::En,
382                sequence: Sequence::First,
383                contributor_role: ContributorRole::Author,
384                name_style: None,
385                given_name: "Jane".to_string(),
386                surname: "Smith".to_string(),
387                suffix: None,
388                affiliations: Affiliations::default(),
389                orcid: None,
390            }
391        }
392
393        #[test]
394        fn valid_person_name_passes() {
395            let person = valid_person_name();
396            assert!(person.validate().is_ok());
397        }
398
399        #[test]
400        fn given_name_invalid_regex_fails() {
401            let mut person = valid_person_name();
402            person.given_name = "123".to_string();
403            assert!(person.validate().is_err());
404        }
405
406        #[test]
407        fn surname_invalid_regex_fails() {
408            let mut person = valid_person_name();
409            person.surname = "456".to_string();
410            assert!(person.validate().is_err());
411        }
412
413        #[test]
414        fn suffix_none_passes() {
415            let mut person = valid_person_name();
416            person.suffix = None;
417            assert!(person.validate().is_ok());
418        }
419
420        #[test]
421        fn with_valid_orcid_passes() {
422            let mut person = valid_person_name();
423            person.orcid = Some(Orcid {
424                authenticated: Some(true),
425                value: "https://orcid.org/0000-0002-1825-0097".to_string(),
426            });
427            assert!(person.validate().is_ok());
428        }
429
430        #[test]
431        fn with_invalid_orcid_fails() {
432            let mut person = valid_person_name();
433            person.orcid = Some(Orcid {
434                authenticated: None,
435                value: "invalid".to_string(),
436            });
437            assert!(person.validate().is_err());
438        }
439
440        #[test]
441        fn with_affiliations_passes() {
442            let mut person = valid_person_name();
443            person.affiliations = Affiliations {
444                institution: vec![Institution {
445                    institution_id: InstitutionId {
446                        institution_type: InstitutionIdType::Ror,
447                        value: "https://ror.org/12345".to_string(),
448                    },
449                }],
450            };
451            assert!(person.validate().is_ok());
452        }
453    }
454
455    mod anonymous {
456        use super::*;
457
458        fn valid_anonymous() -> Anonymous {
459            Anonymous {
460                language: Iso639_1::En,
461                sequence: Sequence::First,
462                contributor_role: ContributorRole::Author,
463                name_style: None,
464            }
465        }
466
467        #[test]
468        fn valid_anonymous_passes() {
469            let anon = valid_anonymous();
470            assert!(anon.validate().is_ok());
471        }
472
473        #[test]
474        fn with_name_style_passes() {
475            let mut anon = valid_anonymous();
476            anon.name_style = Some(NameStyle::Western);
477            assert!(anon.validate().is_ok());
478        }
479    }
480
481    mod organization {
482        use super::*;
483
484        fn valid_organization() -> Organization {
485            Organization {
486                value: "Example University".to_string(),
487                language: Iso639_1::En,
488                sequence: Sequence::First,
489                contributor_role: ContributorRole::Author,
490            }
491        }
492
493        #[test]
494        fn valid_organization_passes() {
495            let org = valid_organization();
496            assert!(org.validate().is_ok());
497        }
498    }
499
500    mod contributors {
501        use super::*;
502
503        fn valid_contributors_person() -> Contributor {
504            Contributor {
505                organization: vec![],
506                person_name: vec![PersonName {
507                    language: Iso639_1::En,
508                    sequence: Sequence::First,
509                    contributor_role: ContributorRole::Author,
510                    name_style: None,
511                    given_name: "Jane".to_string(),
512                    surname: "Smith".to_string(),
513                    suffix: None,
514                    affiliations: Affiliations::default(),
515                    orcid: None,
516                }],
517                anonymous: vec![],
518            }
519        }
520
521        #[test]
522        fn contributors_with_person_passes() {
523            let contrib = valid_contributors_person();
524            assert!(contrib.validate().is_ok());
525        }
526
527        #[test]
528        fn contributors_with_organization_passes() {
529            let contrib = Contributor {
530                organization: vec![Organization {
531                    value: "Example University".to_string(),
532                    language: Iso639_1::En,
533                    sequence: Sequence::First,
534                    contributor_role: ContributorRole::Author,
535                }],
536                person_name: vec![],
537                anonymous: vec![],
538            };
539            assert!(contrib.validate().is_ok());
540        }
541
542        #[test]
543        fn contributors_with_anonymous_passes() {
544            let contrib = Contributor {
545                organization: vec![],
546                person_name: vec![],
547                anonymous: vec![Anonymous {
548                    language: Iso639_1::En,
549                    sequence: Sequence::First,
550                    contributor_role: ContributorRole::Author,
551                    name_style: None,
552                }],
553            };
554            assert!(contrib.validate().is_ok());
555        }
556
557        #[test]
558        fn contributors_all_none_fails() {
559            // Note: There's a TODO comment saying at least one should be present
560            // but there's no validation for it yet
561            // let contrib = Contributors::default();
562            // assert!(contrib.validate().is_err());
563        }
564
565        #[test]
566        fn vector_of_contributors() {
567            // TODO (both test and implementation)
568        }
569    }
570
571    mod original_language_title {
572        use super::*;
573
574        fn valid_original_language_title() -> OriginalLanguageTitle {
575            OriginalLanguageTitle {
576                language: Iso639_1::Es,
577                value: "Título Original".to_string(),
578            }
579        }
580
581        #[test]
582        fn valid_original_language_title_passes() {
583            let title = valid_original_language_title();
584            assert!(title.validate().is_ok());
585        }
586    }
587
588    mod titles {
589        use super::*;
590
591        fn valid_titles() -> Titles {
592            Titles {
593                title: "Example Article Title".to_string(),
594                subtitle: Some("A Comprehensive Study".to_string()),
595                original_language_title: None,
596                orginal_language_subtitle: None,
597            }
598        }
599
600        #[test]
601        fn valid_titles_passes() {
602            let titles = valid_titles();
603            assert!(titles.validate().is_ok());
604        }
605
606        #[test]
607        fn with_original_language_title_passes() {
608            let mut titles = valid_titles();
609            titles.original_language_title = Some(OriginalLanguageTitle {
610                language: Iso639_1::Es,
611                value: "Título Original".to_string(),
612            });
613            assert!(titles.validate().is_ok());
614        }
615
616        #[test]
617        fn subtitle_none_passes() {
618            let mut titles = valid_titles();
619            titles.subtitle = None;
620            assert!(titles.validate().is_ok());
621        }
622    }
623
624    mod publication_date {
625        use super::*;
626
627        fn valid_publication_date() -> PublicationDate {
628            PublicationDate {
629                media_type: MediaTypeDate::Online,
630                month: Some(6),
631                day: Some(15),
632                year: 2024,
633            }
634        }
635
636        #[test]
637        fn valid_publication_date_passes() {
638            let date = valid_publication_date();
639            assert!(date.validate().is_ok());
640        }
641
642        #[test]
643        fn month_none_passes() {
644            let mut date = valid_publication_date();
645            date.month = None;
646            assert!(date.validate().is_ok());
647        }
648
649        #[test]
650        fn day_none_passes() {
651            let mut date = valid_publication_date();
652            date.day = None;
653            assert!(date.validate().is_ok());
654        }
655
656        #[test]
657        fn minimal_date_passes() {
658            let date = PublicationDate {
659                media_type: MediaTypeDate::Online,
660                month: None,
661                day: None,
662                year: 2024,
663            };
664            assert!(date.validate().is_ok());
665        }
666    }
667
668    mod journal_issue {
669        use super::*;
670        use crate::enums::ContentVersion;
671        use crate::journal::metadata::{DoiData, Resource};
672
673        fn valid_journal_issue() -> JournalIssue {
674            JournalIssue {
675                contributors: vec![Contributor {
676                    organization: vec![],
677                    person_name: vec![PersonName {
678                        language: Iso639_1::En,
679                        sequence: Sequence::First,
680                        contributor_role: ContributorRole::Author,
681                        name_style: None,
682                        given_name: "Jane".to_string(),
683                        surname: "Smith".to_string(),
684                        suffix: None,
685                        affiliations: Affiliations::default(),
686                        orcid: None,
687                    }],
688                    anonymous: vec![],
689                }],
690                titles: Some(Titles {
691                    title: "Issue Title".to_string(),
692                    subtitle: None,
693                    original_language_title: None,
694                    orginal_language_subtitle: None,
695                }),
696                publication_date: PublicationDate {
697                    media_type: MediaTypeDate::Online,
698                    month: Some(6),
699                    day: Some(15),
700                    year: 2024,
701                },
702                issue: Some("1".to_string()),
703                special_numbering: None,
704                archive_locations: ArchiveLocations::default(),
705                doi_data: DoiData {
706                    doi: "10.1234/issue.001".to_string(),
707                    timestamp: (),
708                    resource: Resource {
709                        value: "https://example.com/issue/1".to_string(),
710                        mime_type: None,
711                        content_version: ContentVersion::Vor,
712                    },
713                },
714            }
715        }
716
717        #[test]
718        fn valid_journal_issue_passes() {
719            let issue = valid_journal_issue();
720            assert!(issue.validate().is_ok());
721        }
722
723        #[test]
724        fn invalid_publication_date_fails() {
725            let mut issue = valid_journal_issue();
726            issue.publication_date.year = 1000; // too old
727            assert!(issue.validate().is_err());
728        }
729
730        #[test]
731        fn invalid_doi_data_fails() {
732            let mut issue = valid_journal_issue();
733            issue.doi_data.doi = "invalid".to_string();
734            assert!(issue.validate().is_err());
735        }
736
737        #[test]
738        fn minimal_valid_issue_passes() {
739            let issue = JournalIssue {
740                contributors: vec![],
741                titles: None,
742                publication_date: PublicationDate {
743                    media_type: MediaTypeDate::Online,
744                    month: None,
745                    day: None,
746                    year: 2024,
747                },
748                issue: None,
749                special_numbering: None,
750                archive_locations: ArchiveLocations::default(),
751                doi_data: DoiData {
752                    doi: "10.1234/a".to_string(),
753                    timestamp: (),
754                    resource: Resource {
755                        value: "https://example.com".to_string(),
756                        mime_type: None,
757                        content_version: ContentVersion::Vor,
758                    },
759                },
760            };
761            assert!(issue.validate().is_ok());
762        }
763    }
764}