Skip to main content

acorn/schema/standard/
invenio.rs

1//! InvenioRDM metadata schema models
2//!
3//! This module provides Rust types that model the InvenioRDM metadata schema,
4//! which is based on DataCite Metadata Schema v4.x with additions for file management,
5//! access control, versioning, and custom fields.
6//!
7//! InvenioRDM is a turn-key research data management repository platform that
8//! emphasizes interoperability with standards like DataCite, Dublin Core, and DCAT.
9//!
10//! References:
11//! - [InvenioRDM Documentation](https://inveniordm.docs.cern.ch/)
12//! - [Metadata Reference](https://inveniordm.docs.cern.ch/reference/metadata/)
13//!
14#[cfg(feature = "std")]
15use crate::error::ApiResult;
16#[cfg(feature = "std")]
17use crate::io::{read_file, write_file, InputOutput};
18use crate::prelude::*;
19use crate::schema::research_activity::s3a::Status;
20use crate::schema::standard::crosswalk::{self, mapping::datacite_to_invenio, CrosswalkError, FieldValue, Fields, SchemaBuilder, SchemaExtractor};
21use crate::schema::standard::datacite;
22use crate::schema::validate::{is_doi, is_iso_639_language_code};
23#[cfg(feature = "std")]
24use crate::util::MimeType;
25use crate::util::ToProse;
26#[cfg(feature = "std")]
27use crate::PathBuf;
28#[cfg(feature = "std")]
29use color_eyre::eyre::eyre;
30use core::fmt;
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use serde_with::skip_serializing_none;
35use validator::Validate;
36
37/// Collection of InvenioRDM records
38pub type Catalog = Vec<Record>;
39/// Access level enumeration
40#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
41#[serde(rename_all = "lowercase")]
42pub enum AccessLevel {
43    /// Publicly accessible
44    Public,
45    /// Restricted to specific users/owners
46    Restricted,
47}
48/// Access control information
49///
50/// Determines read access for the record and files. Both can be "public" or "restricted".
51/// Embargo information can be specified for restricted access.
52#[skip_serializing_none]
53#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
54pub struct Access {
55    /// Record-level read access
56    pub record: Option<AccessLevel>,
57    /// Files-level read access
58    pub files: Option<AccessLevel>,
59    /// Embargo information
60    #[validate(nested)]
61    pub embargo: Option<Embargo>,
62}
63/// Additional description
64#[skip_serializing_none]
65#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
66pub struct AdditionalDescription {
67    /// Description text
68    pub description: String,
69    /// Description type (abstract, methods, series-information, etc.)
70    #[serde(rename = "type")]
71    pub kind: Option<DescriptionType>,
72    /// Language of the description
73    #[validate(nested)]
74    pub lang: Option<LanguageValue>,
75}
76/// Additional title information
77#[skip_serializing_none]
78#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
79pub struct AdditionalTitle {
80    /// The title text
81    pub title: String,
82    /// Title type (alternative-title, subtitle, translated-title, other)
83    #[serde(rename = "type")]
84    pub kind: Option<TitleType>,
85    /// Language of the title
86    #[validate(nested)]
87    pub lang: Option<LanguageValue>,
88}
89/// Affiliation information
90#[skip_serializing_none]
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
92pub struct Affiliation {
93    /// Affiliation identifier from controlled vocabulary
94    pub id: Option<String>,
95    /// Affiliation name (recommended if no id)
96    pub name: Option<String>,
97}
98/// Award (grant) information
99#[skip_serializing_none]
100#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
101pub struct Award {
102    /// Award identifier from OpenAIRE grant database
103    pub id: Option<String>,
104    /// Localized award title
105    pub title: Option<Value>,
106    /// Grant number assigned by the funder
107    pub number: Option<String>,
108    /// Award identifiers
109    #[validate(nested)]
110    pub identifiers: Option<Vec<Identifier>>,
111}
112/// CodeMeta software metadata
113#[skip_serializing_none]
114#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
115pub struct CodeMeta {
116    /// Repository URL (e.g., GitHub link)
117    #[validate(url)]
118    pub code_repository: Option<String>,
119    /// Programming language from controlled vocabulary
120    // TODO: Validate against database
121    pub programming_language: Option<String>,
122    /// Development status (e.g., "Active", from repostatus vocabulary)
123    pub development_status: Option<Status>,
124}
125/// Contributor to the resource
126///
127/// Similar structure to Creator but with required role field.
128#[skip_serializing_none]
129#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
130pub struct Contributor {
131    /// Person or organization information
132    #[validate(nested)]
133    pub person_or_org: PersonOrOrganization,
134    /// Role of the contributor (required)
135    #[validate(nested)]
136    pub role: ControlledVocabularyValue,
137    /// Affiliations
138    #[validate(nested)]
139    pub affiliations: Option<Vec<Affiliation>>,
140}
141/// Controlled vocabulary value
142#[skip_serializing_none]
143#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
144pub struct ControlledVocabularyValue {
145    /// Identifier from controlled vocabulary
146    pub id: String,
147}
148/// Creator of the resource
149/// ### Note
150/// Compatible with 2. Creator in DataCite
151#[skip_serializing_none]
152#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
153pub struct Creator {
154    /// Person or organization information
155    #[validate(nested)]
156    pub person_or_org: PersonOrOrganization,
157    /// Role of the creator (optional, from controlled vocabulary)
158    #[validate(nested)]
159    pub role: Option<ControlledVocabularyValue>,
160    /// Affiliations
161    #[validate(nested)]
162    pub affiliations: Option<Vec<Affiliation>>,
163}
164/// Custom field extensions
165///
166/// Enables additional metadata fields beyond the standard metadata.
167/// Includes optional pre-defined fields: journal, imprint, thesis, meeting, codemeta.
168#[skip_serializing_none]
169#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
170pub struct CustomFields {
171    /// Journal information
172    pub journal: Option<Journal>,
173    /// Book chapter or article imprint information
174    pub imprint: Option<Imprint>,
175    /// Thesis information
176    pub thesis: Option<Thesis>,
177    /// Meeting or conference information
178    pub meeting: Option<Meeting>,
179    /// CodeMeta software metadata
180    pub codemeta: Option<CodeMeta>,
181}
182/// Date information
183#[skip_serializing_none]
184#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
185pub struct Date {
186    /// Date or date interval in EDTF format
187    pub date: String,
188    /// Date type (accepted, available, collected, etc.)
189    #[serde(rename = "type")]
190    pub kind: DateType,
191    /// Additional description of the date
192    pub description: Option<String>,
193}
194/// Date type information
195#[skip_serializing_none]
196#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
197pub struct DateType {
198    /// Date type identifier from controlled vocabulary
199    pub id: Option<String>,
200    /// Localized type labels
201    pub title: Option<Value>,
202}
203/// Description type information
204#[skip_serializing_none]
205#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
206pub struct DescriptionType {
207    /// Description type identifier from controlled vocabulary
208    pub id: Option<String>,
209    /// Localized type labels
210    pub title: Option<Value>,
211}
212/// Embargo information
213///
214/// Specifies when an embargo must be lifted to make the record publicly accessible.
215#[skip_serializing_none]
216#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
217pub struct Embargo {
218    /// Whether the record is under embargo
219    pub active: bool,
220    /// ISO date string for when embargo lifts (e.g., "2025-12-31")
221    pub until: Option<String>,
222    /// Explanation for the embargo
223    pub reason: Option<String>,
224}
225/// External persistent identifier entry
226#[skip_serializing_none]
227#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate, PartialEq, Eq)]
228pub struct ExternalPersistentIdentifier {
229    /// The identifier value (e.g., "10.1234/rdm.5678")
230    #[validate(custom(function = "is_doi"))]
231    pub identifier: String,
232    /// The provider's identifier used internally (e.g., "datacite")
233    pub provider: String,
234    /// The client identifier for external registration service
235    pub client: Option<String>,
236}
237/// External persistent identifiers
238///
239/// Contains system-managed PIDs like DOI, Handle, OAI-PMH identifiers.
240#[skip_serializing_none]
241#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
242pub struct ExternalPersistentIdentifiers {
243    /// DOI identifier
244    #[validate(nested)]
245    pub doi: Option<ExternalPersistentIdentifier>,
246    /// Concept DOI (versioning identifier)
247    #[serde(rename = "concept-doi")]
248    #[validate(nested)]
249    pub concept_doi: Option<ExternalPersistentIdentifier>,
250    /// Handle identifier
251    #[validate(nested)]
252    pub handle: Option<ExternalPersistentIdentifier>,
253    /// OAI-PMH identifier
254    #[validate(nested)]
255    pub oai: Option<ExternalPersistentIdentifier>,
256}
257/// Files associated with the record
258#[skip_serializing_none]
259#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
260pub struct Files {
261    /// Whether files are enabled (true) or metadata-only (false)
262    pub enabled: bool,
263    /// Associated file entries
264    pub entries: Option<Value>,
265    /// Default preview file path
266    pub default_preview: Option<String>,
267}
268/// Funder information
269#[skip_serializing_none]
270#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
271pub struct Funder {
272    /// Funder identifier from ROR or Open Funder Registry
273    pub id: Option<String>,
274    /// Funder name
275    pub name: Option<String>,
276}
277/// Funding reference information
278#[skip_serializing_none]
279#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
280pub struct FundingReference {
281    /// Funder information
282    #[validate(nested)]
283    pub funder: Funder,
284    /// Award (grant) information
285    pub award: Option<Award>,
286}
287/// Identifier for the resource
288#[skip_serializing_none]
289#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
290pub struct Identifier {
291    /// Identifier value
292    pub identifier: String,
293    /// Identifier scheme (ARK, arXiv, DOI, Handle, ISBN, etc.)
294    pub scheme: String,
295}
296/// Book chapter or article imprint information
297#[skip_serializing_none]
298#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
299pub struct Imprint {
300    /// Title of the larger work (e.g., book/report title)
301    pub title: Option<String>,
302    /// Publication place (e.g., "Oxford, United Kingdom")
303    pub place: Option<String>,
304    /// Page numbers or page range
305    pub pages: Option<String>,
306    /// ISBN
307    pub isbn: Option<String>,
308    /// Edition number
309    pub edition: Option<String>,
310}
311/// Journal publication information
312#[skip_serializing_none]
313#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
314pub struct Journal {
315    /// Journal title
316    pub title: Option<String>,
317    /// ISSN (International Standard Serial Number)
318    pub issn: Option<String>,
319    /// Volume number
320    pub volume: Option<String>,
321    /// Issue number
322    pub issue: Option<String>,
323    /// Page range or article identifier
324    pub pages: Option<String>,
325}
326/// Language information
327#[skip_serializing_none]
328#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
329pub struct Language {
330    /// ISO 639-3 language code
331    #[validate(custom(function = "is_iso_639_language_code"))]
332    pub id: String,
333}
334/// Language value (ISO-639-3 code)
335#[skip_serializing_none]
336#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
337pub struct LanguageValue {
338    /// ISO 639-3 language code
339    #[validate(custom(function = "is_iso_639_language_code"))]
340    pub id: String,
341}
342/// GeoJSON location feature
343#[skip_serializing_none]
344#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
345pub struct LocationFeature {
346    /// GeoJSON geometry object
347    pub geometry: Option<Value>,
348    /// Geographic identifiers
349    pub identifiers: Option<Vec<LocationIdentifier>>,
350    /// Place name or description
351    pub place: Option<String>,
352    /// Additional description
353    pub description: Option<String>,
354}
355/// Geographic location identifier
356#[skip_serializing_none]
357#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
358pub struct LocationIdentifier {
359    /// Identifier value (e.g., GeoNames ID)
360    pub identifier: String,
361    /// Identifier scheme (geonames, tgn)
362    pub scheme: String,
363}
364/// Geographic locations with GeoJSON support
365#[skip_serializing_none]
366#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
367pub struct Locations {
368    /// Array of GeoJSON features
369    pub features: Option<Vec<LocationFeature>>,
370}
371/// Meeting or conference information
372#[skip_serializing_none]
373#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
374pub struct Meeting {
375    /// Meeting or conference title
376    pub title: Option<String>,
377    /// Acronym or abbreviation
378    pub acronym: Option<String>,
379    /// Meeting dates
380    pub dates: Option<String>,
381    /// Meeting location
382    pub place: Option<String>,
383    /// Session identifier
384    pub session: Option<String>,
385    /// Session part identifier
386    pub session_part: Option<String>,
387    /// Conference website URL
388    #[serde(rename = "url")]
389    pub url: Option<String>,
390    /// Meeting identifiers
391    #[validate(nested)]
392    pub identifiers: Option<Vec<Identifier>>,
393}
394/// Descriptive metadata
395///
396/// Core bibliographic and technical metadata following DataCite conventions.
397#[skip_serializing_none]
398#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
399pub struct Metadata {
400    /// Resource type
401    #[validate(required, nested)]
402    pub resource_type: Option<ResourceType>,
403    /// Primary title
404    #[validate(required)]
405    pub title: Option<String>,
406    /// Publication date in EDTF format
407    #[validate(required)]
408    pub publication_date: Option<String>,
409    /// Creators of the resource
410    #[validate(required, nested)]
411    pub creators: Option<Vec<Creator>>,
412    /// Additional titles (alternative, subtitle, translated, etc.)
413    pub additional_titles: Option<Vec<AdditionalTitle>>,
414    /// Primary description
415    pub description: Option<String>,
416    /// Additional descriptions (abstract, methods, etc.)
417    pub additional_descriptions: Option<Vec<AdditionalDescription>>,
418    /// Rights and licenses
419    pub rights: Option<Vec<Right>>,
420    /// Copyright statement
421    pub copyright: Option<String>,
422    /// Contributors to the resource
423    #[validate(nested)]
424    pub contributors: Option<Vec<Contributor>>,
425    /// Subject keywords and classifications
426    #[validate(nested)]
427    pub subjects: Option<Vec<Subject>>,
428    /// Languages used in the resource
429    #[validate(nested)]
430    pub languages: Option<Vec<Language>>,
431    /// Important dates related to the resource
432    #[validate(nested)]
433    pub dates: Option<Vec<Date>>,
434    /// Version number (semantic versioning recommended)
435    pub version: Option<String>,
436    /// Publisher name
437    pub publisher: Option<String>,
438    /// Alternate identifiers (for references not uniquely constrained)
439    #[validate(nested)]
440    pub identifiers: Option<Vec<Identifier>>,
441    /// Related identifiers and works
442    pub related_identifiers: Option<Vec<RelatedIdentifier>>,
443    /// File sizes or extents
444    pub sizes: Option<Vec<String>>,
445    /// File formats/mimetypes
446    /// ### Note
447    /// Compatible with 14. Format in DataCite
448    pub formats: Option<Vec<String>>,
449    /// Geographic locations
450    pub locations: Option<Locations>,
451    /// Funding information
452    #[validate(nested)]
453    pub funding: Option<Vec<FundingReference>>,
454    /// Reference strings
455    #[validate(nested)]
456    pub references: Option<Vec<Reference>>,
457}
458/// Record owner
459#[skip_serializing_none]
460#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
461pub struct Owner {
462    /// User identifier (the id of the owning user)
463    pub user: Option<i32>,
464}
465/// Parent record information
466///
467/// Every record has a parent that connects all versions of a record together
468/// (the concept record). The parent stores shared information like ownership.
469#[skip_serializing_none]
470#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
471pub struct Parent {
472    /// Parent record identifier (concept version PID)
473    pub id: String,
474    /// Parent-level access control
475    #[validate(nested)]
476    pub access: Option<ParentAccess>,
477}
478/// Parent-level access control
479#[skip_serializing_none]
480#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
481pub struct ParentAccess {
482    /// Owner information
483    #[validate(nested)]
484    pub owned_by: Option<Owner>,
485}
486/// Internal persistent identifier (PID) metadata
487///
488/// The `pk` is the database primary key, and `status` indicates the current state
489/// (R = registered, U = unregistered, M = modified).
490#[skip_serializing_none]
491#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate, PartialEq, Eq)]
492pub struct PersistentIdentifier {
493    /// Database primary key
494    pub pk: Option<i32>,
495    /// Status code (R=registered, U=unregistered, M=modified)
496    pub status: Option<String>,
497}
498/// Person or organization information
499#[skip_serializing_none]
500#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
501pub struct PersonOrOrganization {
502    /// Type of name ("personal" or "organizational")
503    #[serde(rename = "type")]
504    pub kind: String,
505    /// Full name (generated from given_name and family_name for persons)
506    pub name: Option<String>,
507    /// Given name (personal only)
508    pub given_name: Option<String>,
509    /// Family name (personal only)
510    pub family_name: Option<String>,
511    /// Person or organization identifiers
512    #[validate(nested)]
513    pub identifiers: Option<Vec<PersonOrOrgIdentifier>>,
514}
515/// Person or organization identifier (ORCID, GND, ISNI, ROR)
516#[skip_serializing_none]
517#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
518// TODO: Validate with struct-level validator
519pub struct PersonOrOrgIdentifier {
520    /// Identifier scheme (orcid, gnd, isni, ror)
521    // TODO: Create enum for PID scheme
522    pub scheme: String,
523    /// Identifier value
524    pub identifier: String,
525}
526/// Top-level InvenioRDM record structure
527///
528/// An InvenioRDM record represents a research object with metadata, files,
529/// access control, and management information.
530#[skip_serializing_none]
531#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
532#[serde(deny_unknown_fields)]
533pub struct Record {
534    /// JSON Schema version for validation
535    #[serde(rename = "$schema")]
536    pub schema: Option<String>,
537    /// Internal record identifier (specific version PID)
538    pub id: Option<String>,
539    /// Internal persistent identifier metadata
540    #[validate(nested)]
541    pub pid: Option<PersistentIdentifier>,
542    /// External persistent identifiers (DOI, Handle, OAI-PMH, etc.)
543    #[validate(nested)]
544    pub pids: Option<ExternalPersistentIdentifiers>,
545    /// Parent record information (concept version)
546    #[validate(nested)]
547    pub parent: Option<Parent>,
548    /// Access control information
549    #[validate(nested)]
550    pub access: Option<Access>,
551    /// Descriptive metadata
552    #[validate(nested)]
553    pub metadata: Option<Metadata>,
554    /// Custom field extensions
555    pub custom_fields: Option<CustomFields>,
556    /// Associated files
557    #[validate(nested)]
558    pub files: Option<Files>,
559    /// Tombstone information for deleted records
560    #[validate(nested)]
561    pub tombstone: Option<Tombstone>,
562    /// Record creation timestamp (UTC)
563    pub created: Option<String>,
564    /// Record last modification timestamp (UTC)
565    pub updated: Option<String>,
566}
567/// Reference string
568#[skip_serializing_none]
569#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
570pub struct Reference {
571    /// Full reference string
572    pub reference: String,
573    /// Identifier scheme if known
574    pub scheme: Option<String>,
575    /// Identifier value if known
576    pub identifier: Option<String>,
577}
578/// Related identifier or work
579#[skip_serializing_none]
580#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
581pub struct RelatedIdentifier {
582    /// Related identifier value
583    pub identifier: String,
584    /// Identifier scheme
585    pub scheme: String,
586    /// Relation type (cites, is-cited-by, is-part-of, etc.)
587    pub relation_type: Option<RelationType>,
588    /// Resource type of the related work
589    pub resource_type: Option<ResourceTypeInfo>,
590}
591/// Relation type information
592#[skip_serializing_none]
593#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
594pub struct RelationType {
595    /// Relation type identifier from controlled vocabulary
596    pub id: Option<String>,
597    /// Localized relation type labels
598    pub title: Option<Value>,
599}
600/// ResourceType information
601#[skip_serializing_none]
602#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
603pub struct ResourceType {
604    /// Resource type identifier from controlled vocabulary
605    pub id: Option<String>,
606    /// Localized human-readable title (e.g., {"en": "Dataset"})
607    pub title: Option<Value>,
608}
609/// Related resource type information
610#[skip_serializing_none]
611#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
612pub struct ResourceTypeInfo {
613    /// Resource type identifier from controlled vocabulary
614    pub id: Option<String>,
615    /// Localized resource type labels
616    pub title: Option<Value>,
617}
618/// Rights and license information
619#[skip_serializing_none]
620#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
621pub struct Right {
622    /// License identifier (e.g., "cc-by-4.0")
623    pub id: Option<String>,
624    /// Localized license title
625    pub title: Option<Value>,
626    /// Localized license description
627    pub description: Option<Value>,
628    /// Link to full license text
629    pub link: Option<String>,
630}
631/// Subject keyword or classification
632#[skip_serializing_none]
633#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
634pub struct Subject {
635    /// Subject identifier from controlled vocabulary
636    pub id: Option<String>,
637    /// Custom subject keyword
638    pub subject: Option<String>,
639}
640/// Thesis information
641#[skip_serializing_none]
642#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
643pub struct Thesis {
644    /// Awarding institution name
645    pub university: Option<String>,
646    /// Faculty or department name
647    pub department: Option<String>,
648    /// Thesis type (PhD, Masters, Bachelors)
649    #[serde(rename = "type")]
650    pub kind: Option<String>,
651    /// Date submitted (format: YYYY-MM-DD)
652    pub date_submitted: Option<String>,
653    /// Date defended (format: YYYY-MM-DD)
654    pub date_defended: Option<String>,
655}
656/// Title type information
657#[skip_serializing_none]
658#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
659pub struct TitleType {
660    /// Title type identifier from controlled vocabulary
661    pub id: Option<String>,
662    /// Localized title type labels
663    pub title: Option<Value>,
664}
665/// Tombstone information for deleted records
666#[skip_serializing_none]
667#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
668pub struct Tombstone {
669    /// Reason for deletion
670    pub reason: String,
671    /// Category of deletion (for statistics)
672    pub category: String,
673    /// User who removed the record
674    #[validate(nested)]
675    pub removed_by: Owner,
676    /// Timestamp of deletion (UTC)
677    pub timestamp: String,
678}
679impl fmt::Display for AccessLevel {
680    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
681        match self {
682            | AccessLevel::Public => write!(f, "public"),
683            | AccessLevel::Restricted => write!(f, "restricted"),
684        }
685    }
686}
687impl TryFrom<datacite::Record> for Record {
688    type Error = CrosswalkError;
689
690    fn try_from(record: datacite::Record) -> Result<Self, Self::Error> {
691        let mapping = datacite_to_invenio();
692        crosswalk::convert(&record, &mapping).map(|(record, _)| record)
693    }
694}
695impl TryFrom<&datacite::Record> for Record {
696    type Error = CrosswalkError;
697
698    fn try_from(record: &datacite::Record) -> Result<Self, Self::Error> {
699        Record::try_from(record.clone())
700    }
701}
702impl SchemaBuilder for Record {
703    fn build_from_fields(fields: &Fields) -> Result<Self, CrosswalkError> {
704        let identifier_str = fields.get_string("identifier").unwrap_or_default();
705        let mut metadata = Metadata {
706            resource_type: None,
707            title: fields.get_string_opt("title"),
708            publication_date: fields.get_number_opt("publication-year").map(|n| format!("{:04}-01-01", n as i32)),
709            creators: None,
710            additional_titles: None,
711            description: fields.get_string_opt("description"),
712            additional_descriptions: None,
713            rights: None,
714            copyright: None,
715            contributors: None,
716            subjects: None,
717            languages: None,
718            dates: None,
719            version: fields.get_string_opt("version"),
720            publisher: None,
721            identifiers: None,
722            related_identifiers: None,
723            sizes: None,
724            formats: None,
725            locations: None,
726            funding: None,
727            references: None,
728        };
729        if let Some(creator_names) = fields.get_string_vec_opt("creators") {
730            metadata.creators = Some(
731                creator_names
732                    .into_iter()
733                    .map(|name| Creator {
734                        person_or_org: PersonOrOrganization {
735                            kind: "personal".to_string(),
736                            name: Some(name),
737                            given_name: None,
738                            family_name: None,
739                            identifiers: None,
740                        },
741                        affiliations: None,
742                        role: None,
743                    })
744                    .collect(),
745            );
746        }
747        if let Some(rt_str) = fields.get_string_opt("resource-type") {
748            metadata.resource_type = Some(ResourceType {
749                id: Some(rt_str),
750                title: None,
751            });
752        }
753        if let Some(lang_str) = fields.get_string_opt("language") {
754            metadata.languages = Some(vec![Language { id: lang_str }]);
755        }
756        if let Some(ids) = fields.get_string_vec_opt("alternate-identifiers") {
757            metadata.identifiers = Some(
758                ids.into_iter()
759                    .map(|identifier| Identifier {
760                        identifier,
761                        scheme: "unknown".to_string(),
762                    })
763                    .collect(),
764            );
765        }
766        if let Some(subjects) = fields.get_string_vec_opt("subjects") {
767            metadata.subjects = Some(
768                subjects
769                    .into_iter()
770                    .map(|subject| Subject {
771                        id: None,
772                        subject: Some(subject),
773                    })
774                    .collect(),
775            );
776        }
777        if let Some(license_iri) = fields.get_iri_opt("license") {
778            metadata.rights = Some(vec![Right {
779                id: None,
780                title: None,
781                description: None,
782                link: Some(license_iri),
783            }]);
784        }
785        if let Some(contributor_names) = fields.get_string_vec_opt("contributors") {
786            metadata.contributors = Some(
787                contributor_names
788                    .into_iter()
789                    .map(|name| Contributor {
790                        person_or_org: PersonOrOrganization {
791                            kind: "personal".to_string(),
792                            name: Some(name),
793                            given_name: None,
794                            family_name: None,
795                            identifiers: None,
796                        },
797                        role: ControlledVocabularyValue {
798                            id: "contributor".to_string(),
799                        },
800                        affiliations: None,
801                    })
802                    .collect(),
803            );
804        }
805        Ok(Record {
806            schema: Some("https://inveniordm.docs.cern.ch/reference/metadata/".to_string()),
807            id: Some(identifier_str.clone()),
808            pid: Some(PersistentIdentifier {
809                pk: None,
810                status: Some("U".to_string()),
811            }),
812            pids: None,
813            parent: None,
814            access: None,
815            metadata: Some(metadata),
816            custom_fields: None,
817            files: None,
818            tombstone: None,
819            created: fields.get_date_opt("created"),
820            updated: fields.get_date_opt("updated"),
821        })
822    }
823}
824impl SchemaExtractor for Record {
825    fn extract_fields(&self) -> Fields {
826        let mut fields = Fields::new();
827        if let Some(pid) = &self.pid {
828            if let Some(pk) = pid.pk {
829                fields.insert("identifier", FieldValue::String(pk.to_string()));
830            }
831        }
832        let has_id = fields.contains_key("identifier");
833        if !has_id {
834            if let Some(id) = &self.id {
835                fields.insert("identifier", FieldValue::String(id.clone()));
836            }
837        }
838        if let Some(metadata) = &self.metadata {
839            // Title (required in Invenio)
840            if let Some(title) = &metadata.title {
841                fields.insert("title", FieldValue::String(title.clone()));
842            }
843            if let Some(creators) = &metadata.creators {
844                if !creators.is_empty() {
845                    let creator_names: Vec<String> = creators.iter().filter_map(|c| c.person_or_org.name.clone()).collect();
846                    if !creator_names.is_empty() {
847                        fields.insert("creators", FieldValue::StringVec(creator_names));
848                    }
849                }
850            }
851            if let Some(pub_date) = &metadata.publication_date {
852                if let Some(year_str) = pub_date.split('-').next() {
853                    if let Ok(year) = year_str.parse::<f64>() {
854                        fields.insert("publication-year", FieldValue::Number(year));
855                    }
856                }
857            }
858            if let Some(resource_type) = &metadata.resource_type {
859                if let Some(id) = &resource_type.id {
860                    fields.insert("resource-type", FieldValue::String(id.clone()));
861                }
862            }
863            if let Some(description) = &metadata.description {
864                fields.insert("description", FieldValue::String(description.clone()));
865            }
866            if let Some(languages) = &metadata.languages {
867                if let Some(first) = languages.first() {
868                    fields.insert("language", FieldValue::String(first.id.clone()));
869                }
870            }
871            if let Some(identifiers) = &metadata.identifiers {
872                if !identifiers.is_empty() {
873                    let id_strings: Vec<String> = identifiers.iter().map(|id| id.identifier.clone()).collect();
874                    fields.insert("alternate-identifiers", FieldValue::StringVec(id_strings));
875                }
876            }
877            if let Some(subjects) = &metadata.subjects {
878                if !subjects.is_empty() {
879                    let subject_strings: Vec<String> = subjects.iter().filter_map(|s| s.subject.clone()).collect();
880                    if !subject_strings.is_empty() {
881                        fields.insert("subjects", FieldValue::StringVec(subject_strings));
882                    }
883                }
884            }
885            if let Some(rights) = &metadata.rights {
886                if let Some(first) = rights.first() {
887                    if let Some(link) = &first.link {
888                        fields.insert("license", FieldValue::IRI(link.clone()));
889                    }
890                }
891            }
892            if let Some(contributors) = &metadata.contributors {
893                if !contributors.is_empty() {
894                    let contributor_names: Vec<String> = contributors.iter().filter_map(|c| c.person_or_org.name.clone()).collect();
895                    if !contributor_names.is_empty() {
896                        fields.insert("contributors", FieldValue::StringVec(contributor_names));
897                    }
898                }
899            }
900        }
901        if let Some(created) = &self.created {
902            fields.insert("created", FieldValue::Date(created.clone()));
903        }
904        if let Some(updated) = &self.updated {
905            fields.insert("updated", FieldValue::Date(updated.clone()));
906        }
907        fields
908    }
909}
910impl ToProse for Record {
911    fn to_prose(&self) -> String {
912        self.metadata
913            .iter()
914            .flat_map(|metadata| metadata.title.iter().cloned())
915            .chain(self.metadata.iter().flat_map(|metadata| metadata.description.iter().cloned()))
916            .chain(
917                self.metadata
918                    .iter()
919                    .flat_map(|metadata| metadata.additional_descriptions.iter().flatten().map(|value| value.description.clone())),
920            )
921            .collect::<Vec<String>>()
922            .join("\n\n")
923    }
924}
925#[cfg(feature = "std")]
926impl InputOutput for Record {
927    fn read(path: impl Into<PathBuf>) -> ApiResult<Record> {
928        let source = path.into();
929        match MimeType::from(source.display().to_string()) {
930            | MimeType::Json => Record::read_json(source),
931            | MimeType::Yaml => Record::read_yaml(source),
932            | _ => Err(eyre!("Unsupported Invenio data file extension")),
933        }
934    }
935    fn read_json(path: PathBuf) -> ApiResult<Record> {
936        #[derive(Deserialize)]
937        #[serde(untagged)]
938        enum JsonInput {
939            One(Box<Record>),
940            Many(Vec<Record>),
941        }
942
943        read_file(path).and_then(|content| {
944            serde_json::from_str::<JsonInput>(&content)
945                .map_err(|why| eyre!("Failed to parse JSON Invenio record — {why}"))
946                .and_then(|value| match value {
947                    | JsonInput::One(record) => Ok(*record),
948                    | JsonInput::Many(records) => match records.len() {
949                        | 1 => records
950                            .into_iter()
951                            .next()
952                            .ok_or_else(|| eyre!("Expected one Invenio record but found none")),
953                        | len => Err(eyre!("Expected one Invenio record but found {len}")),
954                    },
955                })
956        })
957    }
958    fn read_yaml(path: PathBuf) -> ApiResult<Record> {
959        #[derive(Deserialize)]
960        #[serde(untagged)]
961        enum YamlInput {
962            One(Box<Record>),
963            Many(Vec<Record>),
964        }
965
966        read_file(path).and_then(|content| {
967            serde_norway::from_str::<YamlInput>(&content)
968                .map_err(|why| eyre!("Failed to parse YAML Invenio record — {why}"))
969                .and_then(|value| match value {
970                    | YamlInput::One(record) => Ok(*record),
971                    | YamlInput::Many(records) => match records.len() {
972                        | 1 => records
973                            .into_iter()
974                            .next()
975                            .ok_or_else(|| eyre!("Expected one Invenio record but found none")),
976                        | len => Err(eyre!("Expected one Invenio record but found {len}")),
977                    },
978                })
979        })
980    }
981    fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
982        let output = path.into();
983        match MimeType::from(output.display().to_string()) {
984            | MimeType::Json => self.write_json(output),
985            | MimeType::Yaml => self.write_yaml(output),
986            | _ => Err(eyre!("Unsupported Invenio data file extension for writing")),
987        }
988    }
989    fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
990        let output = path.into().with_extension("json");
991        serde_json::to_string_pretty(self)
992            .map_err(|why| eyre!("Failed to serialize JSON Invenio record — {why}"))
993            .and_then(|content| write_file(output, content))
994    }
995    fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
996        let output = path.into().with_extension("yaml");
997        serde_norway::to_string(self)
998            .map_err(|why| eyre!("Failed to serialize YAML Invenio record — {why}"))
999            .and_then(|content| write_file(output, content))
1000    }
1001}