Skip to main content

acorn/schema/standard/crosswalk/
mapping.rs

1//! Field transformation rules for schema conversions
2//!
3//! Defines how fields are mapped from one schema to another,
4//! including transformation logic and rule composition.
5use super::CrosswalkError;
6use crate::prelude::HashMap;
7use crate::prelude::*;
8use core::fmt;
9use serde_json::Value;
10
11/// A transformation function that maps one FieldValue to another
12///
13/// Used to normalize field values during conversion (e.g., convert year to ISO 8601 date).
14pub type FieldTransform = fn(FieldValue) -> Result<FieldValue, String>;
15/// Normalized representation of a metadata field
16///
17/// Covers the most common metadata types: strings, collections, dates, IRIs, and objects.
18#[derive(Clone, Debug)]
19pub enum FieldValue {
20    /// Single text value
21    String(String),
22    /// Multiple text values
23    StringVec(Vec<String>),
24    /// ISO 8601 date or datetime
25    Date(String),
26    /// URI or IRI (distinguished for semantic clarity)
27    IRI(String),
28    /// Numeric value (for byte sizes, years, etc.)
29    Number(f64),
30    /// Nested object (recursive field map)
31    Object(Box<Fields>),
32    /// List of objects
33    ObjectVec(Vec<Fields>),
34    /// Fallback for untyped values (JSON)
35    Json(Value),
36}
37/// A complete mapping specification from one schema to another
38///
39/// Contains all field rules and metadata about the conversion.
40#[derive(Clone)]
41pub struct FieldMapping {
42    /// Source schema identifier (e.g., "datacite", "dcat")
43    pub source: &'static str,
44    /// Target schema identifier
45    pub target: &'static str,
46    /// All mapping rules for this conversion
47    pub rules: Vec<FieldRule>,
48}
49/// A single field mapping rule from source to target schema
50///
51/// Supports one-to-many mappings (one source field → multiple target fields)
52/// via multiple rules with the same source.
53#[derive(Clone)]
54pub struct FieldRule {
55    /// Source field name (kebab-case)
56    pub source: &'static str,
57    /// Target field name(s); if "field1|field2", apply to both
58    pub target: &'static str,
59    /// Optional transformation function
60    pub transform: Option<FieldTransform>,
61    /// Whether this field is required in target schema
62    pub required: bool,
63}
64impl From<HashMap<String, FieldValue>> for Fields {
65    fn from(fields: HashMap<String, FieldValue>) -> Self {
66        Self { fields }
67    }
68}
69impl Fields {
70    /// Create a new empty field map
71    pub fn new() -> Self {
72        Self { fields: HashMap::new() }
73    }
74    /// Insert a field value
75    pub fn insert(&mut self, key: impl Into<String>, value: FieldValue) {
76        self.fields.insert(key.into(), value);
77    }
78    /// Get a field value by key
79    pub fn get(&self, key: &str) -> Option<&FieldValue> {
80        self.fields.get(key)
81    }
82    /// Remove a field by key, returning the value if present
83    pub fn remove(&mut self, key: &str) -> Option<FieldValue> {
84        self.fields.remove(key)
85    }
86    /// Check if a field exists
87    pub fn contains_key(&self, key: &str) -> bool {
88        self.fields.contains_key(key)
89    }
90    /// Get all keys
91    pub fn keys(&self) -> impl Iterator<Item = &String> {
92        self.fields.keys()
93    }
94    /// Get all key-value pairs
95    pub fn iter(&self) -> impl Iterator<Item = (&String, &FieldValue)> {
96        self.fields.iter()
97    }
98    /// Check if field map is empty
99    pub fn is_empty(&self) -> bool {
100        self.fields.is_empty()
101    }
102    /// Number of fields
103    pub fn len(&self) -> usize {
104        self.fields.len()
105    }
106    /// Get string field, returning error with context if missing or wrong type
107    pub fn get_string(&self, key: &str) -> Result<String, String> {
108        self.get(key)
109            .and_then(|v| v.as_string().map(|s| s.to_string()))
110            .ok_or_else(|| format!("expected string field '{key}'"))
111    }
112    /// Get optional string field
113    pub fn get_string_opt(&self, key: &str) -> Option<String> {
114        self.get(key).and_then(|v| v.as_string().map(|s| s.to_string()))
115    }
116    /// Get string vector field
117    pub fn get_string_vec(&self, key: &str) -> Result<Vec<String>, String> {
118        self.get(key)
119            .and_then(|v| v.as_string_vec().map(|sv| sv.to_vec()))
120            .ok_or_else(|| format!("expected string vector field '{key}'"))
121    }
122    /// Get optional string vector field
123    pub fn get_string_vec_opt(&self, key: &str) -> Option<Vec<String>> {
124        self.get(key).and_then(|v| v.as_string_vec().map(|sv| sv.to_vec()))
125    }
126    /// Get date field
127    pub fn get_date(&self, key: &str) -> Result<String, String> {
128        self.get(key)
129            .and_then(|v| v.as_date().map(|d| d.to_string()))
130            .ok_or_else(|| format!("expected date field '{key}'"))
131    }
132    /// Get optional date field
133    pub fn get_date_opt(&self, key: &str) -> Option<String> {
134        self.get(key).and_then(|v| v.as_date().map(|d| d.to_string()))
135    }
136
137    /// Get IRI field
138    pub fn get_iri(&self, key: &str) -> Result<String, String> {
139        self.get(key)
140            .and_then(|v| v.as_iri().map(|iri| iri.to_string()))
141            .ok_or_else(|| format!("expected IRI field '{key}'"))
142    }
143    /// Get optional IRI field
144    pub fn get_iri_opt(&self, key: &str) -> Option<String> {
145        self.get(key).and_then(|v| v.as_iri().map(|iri| iri.to_string()))
146    }
147    /// Get number field
148    pub fn get_number(&self, key: &str) -> Result<f64, String> {
149        self.get(key)
150            .and_then(|v| v.as_number())
151            .ok_or_else(|| format!("expected number field '{key}'"))
152    }
153    /// Get optional number field
154    pub fn get_number_opt(&self, key: &str) -> Option<f64> {
155        self.get(key).and_then(|v| v.as_number())
156    }
157    /// Get object field
158    pub fn get_object(&self, key: &str) -> Result<&Fields, String> {
159        self.get(key)
160            .and_then(|v| v.as_object())
161            .ok_or_else(|| format!("expected object field '{key}'"))
162    }
163    /// Get object vector field
164    pub fn get_object_vec(&self, key: &str) -> Result<&[Fields], String> {
165        self.get(key)
166            .and_then(|v| v.as_object_vec())
167            .ok_or_else(|| format!("expected object vector field '{key}'"))
168    }
169}
170/// Canonical field map — all metadata represented as a normalized dictionary
171///
172/// Keys follow kebab-case convention for consistency.
173/// Values use FieldValue enum for type safety.
174#[derive(Clone, Debug, Default)]
175pub struct Fields {
176    fields: HashMap<String, FieldValue>,
177}
178impl FieldMapping {
179    /// Create a new empty field mapping
180    pub fn new(source: &'static str, target: &'static str) -> Self {
181        Self {
182            source,
183            target,
184            rules: Vec::new(),
185        }
186    }
187    /// Add a rule to this mapping
188    pub fn with_rule(mut self, rule: FieldRule) -> Self {
189        self.rules.push(rule);
190        self
191    }
192    /// Apply all rules to transform source to target
193    ///
194    /// Returns missing non-required fields that could not be mapped.
195    pub fn apply(&self, source: &Fields, target: &mut Fields) -> Result<Vec<String>, CrosswalkError> {
196        let mut missing = Vec::new();
197        for rule in &self.rules {
198            if let Ok(Some(field)) = rule.apply(source, target) {
199                missing.push(field);
200            }
201        }
202        Ok(missing)
203    }
204}
205impl FieldRule {
206    /// Create a new mapping rule with identity transformation
207    pub fn new(source: &'static str, target: &'static str) -> Self {
208        Self {
209            source,
210            target,
211            transform: None,
212            required: false,
213        }
214    }
215    /// Create a required field rule (shorthand for `new(f, t).required()`)
216    pub fn required_field(source: &'static str, target: &'static str) -> Self {
217        Self {
218            source,
219            target,
220            transform: None,
221            required: true,
222        }
223    }
224    /// Create an optional field rule (equivalent to `new(f, t)`)
225    pub fn map(source: &'static str, target: &'static str) -> Self {
226        Self {
227            source,
228            target,
229            transform: None,
230            required: false,
231        }
232    }
233    /// Make this field required
234    pub fn required(mut self) -> Self {
235        self.required = true;
236        self
237    }
238    /// Add a transformation function
239    pub fn with_transform(mut self, transform: FieldTransform) -> Self {
240        self.transform = Some(transform);
241        self
242    }
243    /// Apply this rule to extract value from source and insert into target
244    pub fn apply(&self, source: &Fields, target: &mut Fields) -> Result<Option<String>, CrosswalkError> {
245        match source.get(self.source) {
246            | Some(value) => {
247                let transformed = match self.transform {
248                    | Some(f) => f(value.clone()).map_err(|reason| CrosswalkError::TransformationFailed {
249                        field: self.source.to_string(),
250                        reason,
251                    })?,
252                    | None => value.clone(),
253                };
254                for target_name in self.target.split('|') {
255                    target.insert(target_name.trim(), transformed.clone());
256                }
257                Ok(None)
258            }
259            | None => {
260                if self.required {
261                    Err(CrosswalkError::MissingRequiredField(self.source.to_string()))
262                } else {
263                    Ok(Some(self.source.to_string()))
264                }
265            }
266        }
267    }
268}
269impl fmt::Display for FieldValue {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        match self {
272            | Self::String(s) => write!(f, "{s}"),
273            | Self::StringVec(v) => write!(f, "[{}]", v.join(", ")),
274            | Self::Date(d) => write!(f, "{d}"),
275            | Self::IRI(iri) => write!(f, "{iri}"),
276            | Self::Number(n) => write!(f, "{n}"),
277            | Self::Object(_) => write!(f, "{{...}}"),
278            | Self::ObjectVec(v) => write!(f, "[...{}]", v.len()),
279            | Self::Json(val) => write!(f, "{val}"),
280        }
281    }
282}
283impl From<&Fields> for HashMap<String, FieldValue> {
284    fn from(map: &Fields) -> Self {
285        map.fields.clone()
286    }
287}
288impl FieldValue {
289    /// Get string value, returning None if not a string variant
290    pub fn as_string(&self) -> Option<&str> {
291        match self {
292            | Self::String(s) => Some(s),
293            | _ => None,
294        }
295    }
296    /// Get string vector, returning None if not a StringVec variant
297    pub fn as_string_vec(&self) -> Option<&[String]> {
298        match self {
299            | Self::StringVec(v) => Some(v),
300            | _ => None,
301        }
302    }
303    /// Get date value, returning None if not a Date variant
304    pub fn as_date(&self) -> Option<&str> {
305        match self {
306            | Self::Date(d) => Some(d),
307            | _ => None,
308        }
309    }
310    /// Get IRI value, returning None if not an IRI variant
311    pub fn as_iri(&self) -> Option<&str> {
312        match self {
313            | Self::IRI(iri) => Some(iri),
314            | _ => None,
315        }
316    }
317    /// Get numeric value, returning None if not a Number variant
318    pub fn as_number(&self) -> Option<f64> {
319        match self {
320            | Self::Number(n) => Some(*n),
321            | _ => None,
322        }
323    }
324    /// Get object, returning None if not an Object variant
325    pub fn as_object(&self) -> Option<&Fields> {
326        match self {
327            | Self::Object(map) => Some(map),
328            | _ => None,
329        }
330    }
331    /// Get object vector, returning None if not an ObjectVec variant
332    pub fn as_object_vec(&self) -> Option<&[Fields]> {
333        match self {
334            | Self::ObjectVec(v) => Some(v),
335            | _ => None,
336        }
337    }
338}
339/// Create field mapping rules from DataCite to DCAT
340pub fn datacite_to_dcat() -> FieldMapping {
341    FieldMapping::new("datacite", "dcat")
342        .with_rule(FieldRule::new("doi", "identifier").required())
343        .with_rule(FieldRule::new("title", "title"))
344        .with_rule(FieldRule::new("description", "description"))
345        .with_rule(FieldRule::new("language", "language").with_transform(as_vec))
346        .with_rule(FieldRule::new("creators", "creators"))
347        .with_rule(FieldRule::new("publisher", "publisher"))
348        .with_rule(FieldRule::new("publication-year", "issued").with_transform(year_to_date))
349        .with_rule(FieldRule::new("license", "license"))
350        .with_rule(FieldRule::new("url", "landing_page"))
351        .with_rule(FieldRule::new("subjects", "keywords"))
352        .with_rule(FieldRule::new("resource-type", "type_").with_transform(resource_type_to_iri))
353}
354/// Returns field mapping rules for DataCite → HuWise conversion
355pub fn datacite_to_huwise() -> FieldMapping {
356    FieldMapping::new("datacite", "huwise")
357        .with_rule(FieldRule::map("doi", "identifier").required())
358        .with_rule(FieldRule::map("title", "title"))
359        .with_rule(FieldRule::map("description", "description"))
360        .with_rule(FieldRule::map("creators", "creators"))
361        .with_rule(FieldRule::map("publication-year", "publication-year"))
362        .with_rule(FieldRule::map("resource-type", "resource-type"))
363        .with_rule(FieldRule::map("subjects", "subjects"))
364        .with_rule(FieldRule::map("language", "language"))
365        .with_rule(FieldRule::map("publisher", "publisher"))
366        .with_rule(FieldRule::map("license", "license"))
367        .with_rule(FieldRule::map("version", "version"))
368}
369/// Create field mapping rules from DataCite to Invenio
370pub fn datacite_to_invenio() -> FieldMapping {
371    FieldMapping::new("datacite", "invenio")
372        .with_rule(FieldRule::map("doi", "identifier").required())
373        .with_rule(FieldRule::map("title", "title"))
374        .with_rule(FieldRule::map("description", "description"))
375        .with_rule(FieldRule::map("language", "language"))
376        .with_rule(FieldRule::map("creators", "creators"))
377        .with_rule(FieldRule::map("publication-year", "publication-year").with_transform(extract_year))
378        .with_rule(FieldRule::map("resource-type", "resource-type"))
379        .with_rule(FieldRule::map("license", "license"))
380        .with_rule(FieldRule::map("alternate-identifiers", "alternate-identifiers"))
381        .with_rule(FieldRule::map("subjects", "subjects"))
382        .with_rule(FieldRule::map("contributors", "contributors"))
383        .with_rule(FieldRule::map("version", "version"))
384}
385/// Create field mapping rules from DCAT to DataCite
386pub fn dcat_to_datacite() -> FieldMapping {
387    FieldMapping::new("dcat", "datacite")
388        .with_rule(FieldRule::new("identifier", "doi").required().with_transform(first_of_vec))
389        .with_rule(FieldRule::new("title", "title"))
390        .with_rule(FieldRule::new("description", "description"))
391        .with_rule(FieldRule::new("language", "language").with_transform(first_of_vec))
392        .with_rule(FieldRule::new("creators", "creators"))
393        .with_rule(FieldRule::new("publisher", "publisher"))
394        .with_rule(FieldRule::new("issued", "publication-year").with_transform(extract_year))
395        .with_rule(FieldRule::new("license", "license"))
396        .with_rule(FieldRule::new("type_", "resource-type").with_transform(iri_to_resource_type))
397        .with_rule(FieldRule::new("keywords", "subjects"))
398        .with_rule(FieldRule::new("themes", "subjects"))
399}
400/// Returns field mapping rules for HuWise → DataCite conversion
401pub fn huwise_to_datacite() -> FieldMapping {
402    FieldMapping::new("huwise", "datacite")
403        .with_rule(FieldRule::map("doi", "doi").required())
404        .with_rule(FieldRule::map("title", "title"))
405        .with_rule(FieldRule::map("description", "description"))
406        .with_rule(FieldRule::map("creators", "creators"))
407        .with_rule(FieldRule::map("contributors", "contributors"))
408        .with_rule(FieldRule::map("publication-year", "publication-year"))
409        .with_rule(FieldRule::map("resource-type", "resource-type"))
410        .with_rule(FieldRule::map("subjects", "subjects"))
411        .with_rule(FieldRule::map("language", "language"))
412        .with_rule(FieldRule::map("publisher", "publisher"))
413        .with_rule(FieldRule::map("license", "license"))
414        .with_rule(FieldRule::map("version", "version"))
415}
416/// Create field mapping rules from Invenio to DataCite
417pub fn invenio_to_datacite() -> FieldMapping {
418    FieldMapping::new("invenio", "datacite")
419        .with_rule(FieldRule::map("identifier", "doi").required())
420        .with_rule(FieldRule::map("title", "title"))
421        .with_rule(FieldRule::map("description", "description"))
422        .with_rule(FieldRule::map("language", "language"))
423        .with_rule(FieldRule::map("creators", "creators"))
424        .with_rule(FieldRule::map("publication-year", "publication-year"))
425        .with_rule(FieldRule::map("resource-type", "resource-type"))
426        .with_rule(FieldRule::map("license", "license"))
427        .with_rule(FieldRule::map("alternate-identifiers", "alternate-identifiers"))
428        .with_rule(FieldRule::map("subjects", "subjects"))
429        .with_rule(FieldRule::map("contributors", "contributors"))
430}
431/// Common field transformations across schemas
432/// Extract first element from string vector
433pub fn first_of_vec(value: FieldValue) -> Result<FieldValue, String> {
434    match value {
435        | FieldValue::StringVec(mut v) if !v.is_empty() => Ok(FieldValue::String(v.remove(0))),
436        | FieldValue::StringVec(_) => Err("empty string vector".to_string()),
437        | _ => Err("expected string vector".to_string()),
438    }
439}
440/// Extract all elements from string vector (no-op)
441pub fn as_vec(value: FieldValue) -> Result<FieldValue, String> {
442    match value {
443        | FieldValue::StringVec(_) => Ok(value),
444        | FieldValue::String(s) => Ok(FieldValue::StringVec(vec![s])),
445        | _ => Err("expected string or string vector".to_string()),
446    }
447}
448/// Parse year from ISO 8601 date or accept as-is
449pub fn extract_year(value: FieldValue) -> Result<FieldValue, String> {
450    match value {
451        | FieldValue::Date(d) => {
452            let year = d
453                .split('-')
454                .next()
455                .and_then(|y| y.parse::<i32>().ok())
456                .ok_or_else(|| format!("cannot extract year from date: {d}"))?;
457            Ok(FieldValue::Number(year as f64))
458        }
459        | FieldValue::String(s) => s
460            .parse::<i32>()
461            .map(|y| FieldValue::Number(y as f64))
462            .map_err(|_| format!("cannot parse year from string: {s}")),
463        | FieldValue::Number(n) => Ok(FieldValue::Number(n)),
464        | _ => Err("expected date, string, or number".to_string()),
465    }
466}
467/// Convert year to ISO 8601 date (Jan 1)
468pub fn year_to_date(value: FieldValue) -> Result<FieldValue, String> {
469    match value {
470        | FieldValue::Number(n) => Ok(FieldValue::Date(format!("{:04}-01-01", n as i32))),
471        | FieldValue::String(s) => {
472            let _: i32 = s.parse().map_err(|_| format!("cannot parse year: {s}"))?;
473            Ok(FieldValue::Date(format!("{s}-01-01")))
474        }
475        | _ => Err("expected number or string year".to_string()),
476    }
477}
478/// ResourceTypeGeneral enum to RDF/schema.org IRI
479pub fn resource_type_to_iri(value: FieldValue) -> Result<FieldValue, String> {
480    match value {
481        | FieldValue::String(s) => {
482            let iri = match s.as_str() {
483                | "Dataset" => "http://www.w3.org/ns/dcat#Dataset",
484                | "Software" => "https://schema.org/SoftwareApplication",
485                | "Audiovisual" => "https://schema.org/VideoObject",
486                | "Book" => "https://schema.org/Book",
487                | "BookChapter" => "https://schema.org/Chapter",
488                | "ConferencePaper" => "https://schema.org/ScholarlyArticle",
489                | "Dissertation" => "https://schema.org/Thesis",
490                | "Image" => "https://schema.org/ImageObject",
491                | "Journal" => "https://schema.org/Periodical",
492                | "JournalArticle" => "https://schema.org/ScholarlyArticle",
493                | "Report" => "https://schema.org/Report",
494                | "Text" => "https://schema.org/CreativeWork",
495                | _ => return Err(format!("unknown resource type: {s}")),
496            };
497            Ok(FieldValue::IRI(iri.to_string()))
498        }
499        | _ => Err("expected string resource type".to_string()),
500    }
501}
502/// RDF/schema.org IRI to ResourceTypeGeneral enum value
503pub fn iri_to_resource_type(value: FieldValue) -> Result<FieldValue, String> {
504    match value {
505        | FieldValue::IRI(iri) => {
506            let type_str = match iri.as_str() {
507                | "http://www.w3.org/ns/dcat#Dataset" => "Dataset",
508                | "https://schema.org/SoftwareApplication" => "Software",
509                | "https://schema.org/VideoObject" => "Audiovisual",
510                | "https://schema.org/Book" => "Book",
511                | "https://schema.org/Chapter" => "BookChapter",
512                | "https://schema.org/ScholarlyArticle" => "JournalArticle",
513                | "https://schema.org/Thesis" => "Dissertation",
514                | "https://schema.org/ImageObject" => "Image",
515                | "https://schema.org/Periodical" => "Journal",
516                | "https://schema.org/Report" => "Report",
517                | "https://schema.org/CreativeWork" => "Text",
518                | _ => return Err(format!("unknown IRI: {iri}")),
519            };
520            Ok(FieldValue::String(type_str.to_string()))
521        }
522        | _ => Err("expected IRI resource type".to_string()),
523    }
524}