Skip to main content

acorn/schema/standard/
cff.rs

1//! Module with data model and utilities for parsing and working with CITATION File Format (CFF) files
2//!
3//! See <https://github.com/citation-file-format/citation-file-format/blob/main/schema-guide.md> for more information on the CFF schema.
4#[cfg(feature = "std")]
5use crate::error::ApiResult;
6#[cfg(feature = "std")]
7use crate::io::{read_file, write_file, InputOutput, License};
8#[cfg(feature = "std")]
9use crate::prelude::PathBuf;
10use crate::prelude::*;
11use crate::schema::validate::{
12    is_commit, is_country_code, is_date, is_doi, is_isbn, is_orcid, is_phone_number, is_semantic_version, is_states, IntegerOrString, MonthValue,
13    NumberOrString, PostalCode, YearValue,
14};
15#[cfg(not(feature = "std"))]
16use crate::util::License;
17#[cfg(feature = "std")]
18use crate::util::MimeType;
19use crate::util::{MarkdownSupport, ToProse};
20#[cfg(feature = "std")]
21use color_eyre::eyre::eyre;
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use serde_with::skip_serializing_none;
25use validator::{Validate, ValidationErrors};
26
27/// Collection of CFF records
28pub type Catalog = Vec<Cff>;
29/// Author or contact actor represented as person or entity
30#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
31#[serde(untagged)]
32pub enum Agent {
33    /// Collective entity representation
34    Entity(Entity),
35    /// Individual person representation
36    Person(Person),
37}
38/// Primary work type in the CFF root object
39#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
40#[serde(rename_all = "lowercase")]
41pub enum CffType {
42    /// Dataset output
43    Dataset,
44    /// Software output
45    Software,
46}
47/// Identifier type for CFF identifier objects.
48#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
49#[serde(rename_all = "lowercase")]
50pub enum IdentifierType {
51    /// Digital Object Identifier
52    Doi,
53    /// Any other identifier namespace
54    Other,
55    /// Software Heritage identifier
56    Swh,
57    /// URL identifier
58    Url,
59}
60/// Publication status value for a CFF reference
61#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
62#[serde(rename_all = "kebab-case")]
63pub enum PublicationStatus {
64    /// Abstract.
65    Abstract,
66    /// Advance online publication
67    AdvanceOnline,
68    /// In preparation
69    InPreparation,
70    /// In press
71    InPress,
72    /// Preprint
73    Preprint,
74    /// Submitted
75    Submitted,
76}
77/// Reference type enumeration from CFF 1.2
78#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
79#[serde(rename_all = "kebab-case")]
80pub enum ReferenceType {
81    /// Artwork
82    Art,
83    /// Journal or general article
84    Article,
85    /// Audiovisual work
86    Audiovisual,
87    /// Bill
88    Bill,
89    /// Blog post
90    Blog,
91    /// Book
92    Book,
93    /// Catalogue
94    Catalogue,
95    /// Conference proceedings event
96    Conference,
97    /// Conference paper
98    ConferencePaper,
99    /// Data output
100    Data,
101    /// Database
102    Database,
103    /// Dictionary entry
104    Dictionary,
105    /// Edited work
106    EditedWork,
107    /// Encyclopedia entry
108    Encyclopedia,
109    /// Film or broadcast
110    FilmBroadcast,
111    /// Generic type
112    Generic,
113    /// Government document
114    GovernmentDocument,
115    /// Grant
116    Grant,
117    /// Hearing
118    Hearing,
119    /// Historical work
120    HistoricalWork,
121    /// Legal case
122    LegalCase,
123    /// Legal rule
124    LegalRule,
125    /// Magazine article
126    MagazineArticle,
127    /// Manual
128    Manual,
129    /// Map
130    Map,
131    /// Multimedia
132    Multimedia,
133    /// Musical work
134    Music,
135    /// Newspaper article
136    NewspaperArticle,
137    /// Pamphlet
138    Pamphlet,
139    /// Patent
140    Patent,
141    /// Personal communication
142    PersonalCommunication,
143    /// Proceedings volume
144    Proceedings,
145    /// Report
146    Report,
147    /// Serial publication
148    Serial,
149    /// Slide deck
150    Slides,
151    /// Software
152    Software,
153    /// Software code
154    SoftwareCode,
155    /// Software container image
156    SoftwareContainer,
157    /// Software executable
158    SoftwareExecutable,
159    /// Software virtual machine image
160    SoftwareVirtualMachine,
161    /// Sound recording
162    SoundRecording,
163    /// Standard
164    Standard,
165    /// Statute
166    Statute,
167    /// Thesis
168    Thesis,
169    /// Unpublished material
170    Unpublished,
171    /// Video
172    Video,
173    /// Website
174    Website,
175}
176/// Top-level Citation File Format (CFF) record
177#[skip_serializing_none]
178#[derive(Clone, Debug, eserde::Deserialize, JsonSchema, PartialEq, Serialize, Validate)]
179#[serde(deny_unknown_fields, rename_all = "kebab-case")]
180pub struct Cff {
181    /// Human-readable abstract for the software or dataset
182    #[serde(rename = "abstract")]
183    pub abstract_text: Option<String>,
184    /// People or organizations credited as authors
185    #[validate(nested)]
186    #[eserde(compat)]
187    pub authors: Vec<Agent>,
188    /// CFF schema version
189    pub cff_version: String,
190    /// Commit hash or revision number
191    pub commit: Option<String>,
192    /// Contact person(s) or entity(ies)
193    #[validate(nested)]
194    #[eserde(compat)]
195    pub contact: Option<Vec<Agent>>,
196    /// Release date in YYYY-MM-DD format
197    #[validate(custom(function = "is_date"))]
198    pub date_released: Option<String>,
199    /// Canonical DOI for the work
200    #[validate(custom(function = "is_doi"))]
201    pub doi: Option<String>,
202    /// Additional identifiers for the work
203    #[validate(nested)]
204    #[eserde(compat)]
205    pub identifiers: Option<Vec<Identifier>>,
206    /// Keywords describing the work
207    pub keywords: Option<Vec<String>>,
208    /// SPDX license identifier(s)
209    #[validate(nested)]
210    #[eserde(compat)]
211    pub license: Option<License>,
212    /// URL for non-standard license text
213    #[validate(url)]
214    pub license_url: Option<String>,
215    /// Instructional message for citation users
216    pub message: String,
217    /// Preferred citation metadata for credit redirection
218    #[validate(nested)]
219    #[eserde(compat)]
220    pub preferred_citation: Option<Reference>,
221    /// References to related work
222    #[validate(nested)]
223    #[eserde(compat)]
224    pub references: Option<Vec<Reference>>,
225    /// URL of a generic repository/archive
226    #[validate(url)]
227    pub repository: Option<String>,
228    /// URL of a build artifact repository entry
229    #[validate(url)]
230    pub repository_artifact: Option<String>,
231    /// URL of a source code repository
232    #[validate(url)]
233    pub repository_code: Option<String>,
234    /// Title of the work
235    pub title: String,
236    /// Type of the work described by this CFF record
237    #[serde(rename = "type")]
238    #[eserde(compat)]
239    pub kind: Option<CffType>,
240    /// Landing page URL
241    #[validate(url)]
242    pub url: Option<String>,
243    /// Version identifier for the work
244    #[validate(custom(function = "is_semantic_version"))]
245    pub version: Option<String>,
246}
247impl Cff {
248    /// Parse CFF records embedded in fenced Markdown blocks.
249    #[cfg(feature = "analysis")]
250    pub(crate) fn embedded(content: &str) -> Vec<Self> {
251        content
252            .split("```")
253            .enumerate()
254            .filter(|(index, _)| index % 2 == 1)
255            .filter_map(|(_, block)| {
256                let value = block
257                    .strip_prefix("cff\n")
258                    .or_else(|| block.strip_prefix("yaml\n"))
259                    .or_else(|| block.strip_prefix("yml\n"))
260                    .unwrap_or(block);
261                value
262                    .contains("cff-version")
263                    .then(|| serde_norway::from_str::<Self>(value).ok())
264                    .flatten()
265            })
266            .collect()
267    }
268}
269/// Organization, team, or other non-person entity metadata used in CFF
270#[skip_serializing_none]
271#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, Validate)]
272#[serde(deny_unknown_fields, rename_all = "kebab-case")]
273pub struct Entity {
274    /// Street or postal address
275    pub address: Option<String>,
276    /// Alias or abbreviation
277    pub alias: Option<String>,
278    /// City name
279    pub city: Option<String>,
280    /// ISO 3166-1 alpha-2 country code
281    #[validate(custom(function = "is_country_code"))]
282    pub country: Option<String>,
283    /// Optional end date when the entity is time-bound
284    #[validate(custom(function = "is_date"))]
285    pub date_end: Option<String>,
286    /// Optional start date when the entity is time-bound
287    #[validate(custom(function = "is_date"))]
288    pub date_start: Option<String>,
289    /// Email address
290    #[validate(email)]
291    pub email: Option<String>,
292    /// Fax number
293    pub fax: Option<String>,
294    /// Free-form location details
295    pub location: Option<String>,
296    /// Entity display name
297    pub name: String,
298    /// ORCID URI
299    #[validate(custom(function = "is_orcid"))]
300    pub orcid: Option<String>,
301    /// Postal code value
302    #[validate(nested)]
303    pub postal_code: Option<PostalCode>,
304    /// Region/state/province
305    pub region: Option<String>,
306    /// Telephone number
307    #[validate(custom(function = "is_phone_number"))]
308    pub tel: Option<String>,
309    /// Website URL
310    #[validate(url)]
311    pub website: Option<String>,
312}
313/// Identifier object used in root records and references
314#[skip_serializing_none]
315#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Validate)]
316#[serde(deny_unknown_fields, rename_all = "kebab-case")]
317pub struct Identifier {
318    /// Optional note describing this specific identifier
319    pub description: Option<String>,
320    /// Identifier category
321    #[serde(rename = "type")]
322    pub kind: IdentifierType,
323    /// Identifier value
324    pub value: String,
325}
326/// Individual person metadata used in CFF.
327#[skip_serializing_none]
328#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, Validate)]
329#[serde(deny_unknown_fields, rename_all = "kebab-case")]
330pub struct Person {
331    /// Street or postal address
332    pub address: Option<String>,
333    /// Affiliation of the person
334    pub affiliation: Option<String>,
335    /// Alias or handle
336    pub alias: Option<String>,
337    /// City name
338    pub city: Option<String>,
339    /// ISO 3166-1 alpha-2 country code
340    #[validate(custom(function = "is_country_code"))]
341    pub country: Option<String>,
342    /// Email address
343    #[validate(email)]
344    pub email: Option<String>,
345    /// Family names
346    pub family_names: Option<String>,
347    /// Fax number
348    pub fax: Option<String>,
349    /// Given names
350    pub given_names: Option<String>,
351    /// Name particle such as "von"
352    pub name_particle: Option<String>,
353    /// Name suffix such as "Jr."
354    pub name_suffix: Option<String>,
355    /// ORCID URI
356    #[validate(custom(function = "is_orcid"))]
357    pub orcid: Option<String>,
358    /// Postal code value
359    #[validate(nested)]
360    pub postal_code: Option<PostalCode>,
361    /// Region/state/province
362    pub region: Option<String>,
363    /// Telephone number
364    #[validate(custom(function = "is_phone_number"))]
365    pub tel: Option<String>,
366    /// Website URL
367    #[validate(url)]
368    pub website: Option<String>,
369}
370/// Related work metadata used by `preferred-citation` and `references`.
371#[skip_serializing_none]
372#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, Validate)]
373#[serde(deny_unknown_fields, rename_all = "kebab-case")]
374pub struct Reference {
375    /// Abbreviation of the referenced work
376    pub abbreviation: Option<String>,
377    /// Work abstract or synopsis
378    #[serde(rename = "abstract")]
379    pub abstract_text: Option<String>,
380    /// Authors of the related work
381    #[validate(nested)]
382    pub authors: Vec<Agent>,
383    /// DOI of a collection containing the work
384    #[validate(custom(function = "is_doi"))]
385    pub collection_doi: Option<String>,
386    /// Title of a collection or proceedings
387    pub collection_title: Option<String>,
388    /// Type of collection containing the work
389    pub collection_type: Option<String>,
390    /// Commit hash or revision number
391    #[validate(custom(function = "is_commit"))]
392    pub commit: Option<String>,
393    /// Conference where the work was presented
394    #[validate(nested)]
395    pub conference: Option<Entity>,
396    /// Contact person(s) or entity(ies) for the work
397    #[validate(nested)]
398    pub contact: Option<Vec<Agent>>,
399    /// Copyright information
400    pub copyright: Option<String>,
401    /// Data type of a dataset
402    pub data_type: Option<String>,
403    /// Name of the database storing or serving the work
404    pub database: Option<String>,
405    /// Provider of the database storing or serving the work
406    pub database_provider: Option<Entity>,
407    /// Date the work was accessed
408    #[validate(custom(function = "is_date"))]
409    pub date_accessed: Option<String>,
410    /// Date the work was downloaded
411    #[validate(custom(function = "is_date"))]
412    pub date_downloaded: Option<String>,
413    /// Date the work was published
414    #[validate(custom(function = "is_date"))]
415    pub date_published: Option<String>,
416    /// Date the work was released
417    #[validate(custom(function = "is_date"))]
418    pub date_released: Option<String>,
419    /// Department where the work was produced
420    // TODO: Map to research_activity::contact::organization
421    pub department: Option<String>,
422    /// DOI of the related work
423    #[validate(custom(function = "is_doi"))]
424    pub doi: Option<String>,
425    /// Edition of the work
426    pub edition: Option<String>,
427    /// Editors of the work
428    #[validate(nested)]
429    pub editors: Option<Vec<Agent>>,
430    /// Editors of the series containing the work
431    #[validate(nested)]
432    pub editors_series: Option<Vec<Agent>>,
433    /// End page of the work
434    #[validate(nested)]
435    pub end: Option<IntegerOrString>,
436    /// Entry in a collection that constitutes the work
437    pub entry: Option<String>,
438    /// Name of the electronic file containing the work
439    pub filename: Option<String>,
440    /// Representation format of the work
441    // TODO: Validate file format
442    pub format: Option<String>,
443    /// Additional identifiers for the related work
444    #[validate(nested)]
445    pub identifiers: Option<Vec<Identifier>>,
446    /// Institution where the work was produced or published
447    #[validate(nested)]
448    pub institution: Option<Entity>,
449    /// ISBN of the work
450    #[validate(custom(function = "is_isbn"))]
451    pub isbn: Option<String>,
452    /// ISSN of the work
453    // TODO: Create is_issn (8-digit string with optional hyphen)
454    pub issn: Option<String>,
455    /// Issue of a periodical containing the work
456    pub issue: Option<NumberOrString>,
457    /// Publication date of the periodical issue
458    #[validate(custom(function = "is_date"))]
459    pub issue_date: Option<String>,
460    /// Title of the periodical issue
461    pub issue_title: Option<String>,
462    /// Journal or periodical name
463    pub journal: Option<String>,
464    /// Keywords associated with the related work
465    pub keywords: Option<Vec<String>>,
466    /// Languages of the work
467    pub languages: Option<Vec<String>>,
468    /// License declaration for the work
469    #[validate(nested)]
470    pub license: Option<License>,
471    /// URL for non-standard license text
472    #[validate(url)]
473    pub license_url: Option<String>,
474    /// Ending line of code where the work ends
475    pub loc_end: Option<IntegerOrString>,
476    /// Starting line of code where the work starts
477    pub loc_start: Option<IntegerOrString>,
478    /// Location of the work
479    #[validate(nested)]
480    pub location: Option<Entity>,
481    /// Medium of the work
482    pub medium: Option<String>,
483    /// Publication month
484    #[validate(nested)]
485    pub month: Option<MonthValue>,
486    /// NIHMS identifier (NIHMSID)
487    /// ### Note
488    /// NIHMSID is a preliminary article identifier that applies only to manuscripts deposited through the NIH (National Institutes of Health) Manuscript Submission (NIHMS) system
489    /// <div class="warning">NIHMSIDs do not have a public schema and are not validated</div>
490    pub nihmsid: Option<String>,
491    /// Notes pertaining to the work
492    pub notes: Option<String>,
493    /// Accession number for the work
494    pub number: Option<NumberOrString>,
495    /// Number of volumes in the containing collection
496    pub number_volumes: Option<IntegerOrString>,
497    /// Number of pages of the work
498    pub pages: Option<IntegerOrString>,
499    /// States for which a patent is granted
500    #[validate(custom(function = "is_states"))]
501    pub patent_states: Option<Vec<String>>,
502    /// PMCID identifier
503    pub pmcid: Option<String>,
504    /// Publisher of the work
505    #[validate(nested)]
506    pub publisher: Option<Entity>,
507    /// Recipients of a personal communication
508    #[validate(nested)]
509    pub recipients: Option<Vec<Agent>>,
510    /// Repository/archive URL
511    #[validate(url)]
512    pub repository: Option<String>,
513    /// Build artifact repository URL
514    #[validate(url)]
515    pub repository_artifact: Option<String>,
516    /// Source code repository URL
517    #[validate(url)]
518    pub repository_code: Option<String>,
519    /// Scope note describing how the reference applies (e.g., the section of the work it adheres to)
520    /// ### Example
521    /// `"Supplement 2: Additional material"`
522    pub scope: Option<String>,
523    /// Referenced section of the work
524    #[validate(nested)]
525    pub section: Option<NumberOrString>,
526    /// Senders of a personal communication
527    #[validate(nested)]
528    pub senders: Option<Vec<Agent>>,
529    /// Start page of the work
530    pub start: Option<IntegerOrString>,
531    /// Publication status of the work
532    pub status: Option<PublicationStatus>,
533    /// Referenced term for dictionary/encyclopedia works
534    pub term: Option<String>,
535    /// Thesis type
536    pub thesis_type: Option<String>,
537    /// Title of the related work
538    pub title: String,
539    /// Translators of the work
540    #[validate(nested)]
541    pub translators: Option<Vec<Agent>>,
542    /// Reference type
543    #[serde(rename = "type")]
544    pub kind: ReferenceType,
545    /// Landing page URL
546    #[validate(url)]
547    pub url: Option<String>,
548    /// Version of the related work
549    #[validate(custom(function = "is_semantic_version"))]
550    pub version: Option<String>,
551    /// Volume of the periodical containing the work
552    pub volume: Option<IntegerOrString>,
553    /// Title of the volume containing the work
554    pub volume_title: Option<String>,
555    /// Year of publication
556    #[validate(nested)]
557    pub year: Option<YearValue>,
558    /// Original year of publication
559    #[validate(nested)]
560    pub year_original: Option<YearValue>,
561}
562impl Default for Cff {
563    fn default() -> Self {
564        Self {
565            abstract_text: None,
566            authors: Vec::new(),
567            cff_version: "1.2.0".to_string(),
568            commit: None,
569            contact: None,
570            date_released: None,
571            doi: None,
572            identifiers: None,
573            keywords: None,
574            license: None,
575            license_url: None,
576            message: "If you use this software, please cite it using the metadata provided in this file.".to_string(),
577            preferred_citation: None,
578            references: None,
579            repository: None,
580            repository_artifact: None,
581            repository_code: None,
582            title: String::new(),
583            kind: None,
584            url: None,
585            version: None,
586        }
587    }
588}
589impl MarkdownSupport for Cff {
590    fn to_markdown(&self) -> String {
591        serde_norway::to_string(self).unwrap_or_default()
592    }
593}
594impl ToProse for Cff {
595    fn to_prose(&self) -> String {
596        [Some(self.title.to_string()), self.abstract_text.clone(), Some(self.message.clone())]
597            .into_iter()
598            .flatten()
599            .collect::<Vec<String>>()
600            .join("\n\n")
601    }
602}
603#[cfg(feature = "std")]
604impl InputOutput for Cff {
605    fn read(path: impl Into<PathBuf>) -> ApiResult<Cff> {
606        let source = path.into();
607        match MimeType::from(source.display().to_string()) {
608            | MimeType::Cff | MimeType::Yaml => Cff::read_yaml(source),
609            | MimeType::Json => Cff::read_json(source),
610            | _ => Err(eyre!("Unsupported CFF data file extension")),
611        }
612    }
613    fn read_cff(path: impl Into<PathBuf>) -> ApiResult<Cff> {
614        Cff::read_yaml(path.into())
615    }
616    fn read_json(path: PathBuf) -> ApiResult<Cff> {
617        read_file(path.clone()).and_then(|content| {
618            eserde::json::from_str::<Cff>(&content).map_err(|errors| {
619                let details: Vec<String> = errors
620                    .iter()
621                    .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
622                    .collect();
623                eyre!("{}", details.join("\n"))
624            })
625        })
626    }
627    fn read_yaml(path: PathBuf) -> ApiResult<Cff> {
628        read_file(path.clone()).and_then(|content| serde_norway::from_str(&content).map_err(|why| eyre!("Failed to parse YAML CFF — {why}")))
629    }
630    fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
631        let output = path.into();
632        match MimeType::from(output.display().to_string()) {
633            | MimeType::Cff => self.write_cff(output),
634            | MimeType::Json => self.write_json(output),
635            | MimeType::Yaml => self.write_yaml(output),
636            | _ => Err(eyre!("Unsupported CFF data file extension for writing")),
637        }
638    }
639    fn write_cff(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
640        let output = path.into().with_extension("cff");
641        serde_norway::to_string(self)
642            .map_err(|why| eyre!("Failed to serialize CFF — {why}"))
643            .and_then(|content| write_file(output, content))
644    }
645    fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
646        let output = path.into().with_extension("json");
647        serde_json::to_string_pretty(self)
648            .map_err(|why| eyre!("Failed to serialize JSON CFF — {why}"))
649            .and_then(|content| write_file(output, content))
650    }
651    fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
652        let output = path.into().with_extension("yaml");
653        serde_norway::to_string(self)
654            .map_err(|why| eyre!("Failed to serialize YAML CFF — {why}"))
655            .and_then(|content| write_file(output, content))
656    }
657}
658impl Validate for Agent {
659    fn validate(&self) -> Result<(), ValidationErrors> {
660        match self {
661            | Self::Entity(value) => value.validate(),
662            | Self::Person(value) => value.validate(),
663        }
664    }
665}