Skip to main content

acorn/schema/standard/crosswalk/
mod.rs

1//! Generalized metadata schema crosswalk infrastructure
2//!
3//! This module provides trait abstractions and utilities for bidirectional conversion
4//! among metadata standards: CFF, DataCite, DCAT, InvenioRDM, and HuWise.
5//!
6//! # Design Principles
7//!
8//! - **Field-level abstraction**: Conversions decompose into field extraction, mapping, and building
9//! - **Lossy conversion tracking**: ConversionWarning types document data loss
10//! - **Registry-driven discovery**: SchemaRegistry enables dynamic conversion discovery
11//! - **Canonical intermediate format**: FieldMap allows type-safe conversions without serde_json
12//! - **Trait-based extensibility**: Extractors and builders enable schema-specific logic
13//!
14//! # Conversion Strategies
15//!
16//! Two usage patterns:
17//!
18//! 1. **TryFrom trait (infallible at type level, propagates errors)**:
19//!    ```ignore
20//!    let dcat_dataset: dcat::Dataset = datacite_record.try_into()?;
21//!    ```
22//!
23//! 2. **Crosswalk trait (error-aware)**:
24//!    ```ignore
25//!    let (dcat_dataset, warnings) = datacite_record.crosswalk()?;
26//!    for warning in warnings {
27//!        eprintln!("Data loss: {}", warning);
28//!    }
29//!    ```
30//!
31//! # Field Mapping
32//!
33//! See [`field_map`] for the canonical intermediate representation.
34//! Field mappings are defined in submodules and reused across conversions.
35//!
36//! # Schema Extractors & Builders
37//!
38//! Each schema provides implementations of [`SchemaExtractor`] and [`SchemaBuilder`]
39//! in the [`extractors`] and [`builders`] submodules.
40use crate::prelude::*;
41use core::fmt;
42
43pub mod mapping;
44
45pub use mapping::{FieldMapping, FieldRule, FieldValue, Fields};
46
47/// Error type for crosswalk operations
48#[derive(Clone, Debug)]
49pub enum CrosswalkError {
50    /// Field required by target schema is missing in source
51    MissingRequiredField(String),
52    /// Field transformation failed (e.g., invalid date format)
53    TransformationFailed {
54        /// The field name that failed to transform
55        field: String,
56        /// The reason for the transformation failure
57        reason: String,
58    },
59    /// Build failed (e.g., struct construction error)
60    BuildFailed(String),
61    /// Content parsing failed (JSON/YAML)
62    ParseFailed(String),
63    /// Output serialization failed
64    SerializeFailed(String),
65}
66/// Type of data loss during conversion
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub enum FieldLossType {
69    /// No equivalent field in target schema
70    NoEquivalent,
71    /// Mapped to similar field but semantics differ
72    Approximated,
73    /// Complex structure simplified to scalar or vice versa
74    Simplified,
75    /// Field intentionally omitted (e.g., requires special handling)
76    Omitted,
77    /// Field mapping exists but target-specific block not available (HuWise only)
78    BlockNotSelected,
79}
80/// Documents data loss during a schema conversion
81#[derive(Clone, Debug)]
82pub struct ConversionWarning {
83    /// Source schema name (e.g., "datacite", "dcat")
84    pub from: &'static str,
85    /// Target schema name
86    pub to: &'static str,
87    /// Source field name that was not fully mapped
88    pub field: String,
89    /// Type of data loss
90    pub loss_type: FieldLossType,
91    /// Optional details about the loss (e.g., "sizes and formats omitted")
92    pub details: Option<String>,
93}
94impl fmt::Display for ConversionWarning {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(
97            f,
98            "{} → {}: {} ({}) {}",
99            self.from,
100            self.to,
101            self.field,
102            self.loss_type,
103            self.details.as_deref().unwrap_or("")
104        )
105    }
106}
107impl ConversionWarning {
108    /// Create a new warning for a field with no equivalent in target schema
109    pub fn no_equivalent(from: &'static str, to: &'static str, field: impl Into<String>) -> Self {
110        Self {
111            from,
112            to,
113            field: field.into(),
114            details: None,
115            loss_type: FieldLossType::NoEquivalent,
116        }
117    }
118    /// Create a new warning for an approximated mapping
119    pub fn approximated(from: &'static str, to: &'static str, field: impl Into<String>, details: impl Into<String>) -> Self {
120        Self {
121            from,
122            to,
123            field: field.into(),
124            details: Some(details.into()),
125            loss_type: FieldLossType::Approximated,
126        }
127    }
128    /// Create a new warning for a simplified mapping
129    pub fn simplified(from: &'static str, to: &'static str, field: impl Into<String>, details: impl Into<String>) -> Self {
130        Self {
131            from,
132            to,
133            field: field.into(),
134            details: Some(details.into()),
135            loss_type: FieldLossType::Simplified,
136        }
137    }
138    /// Create a new warning for an omitted field
139    pub fn omitted(from: &'static str, to: &'static str, field: impl Into<String>, details: impl Into<String>) -> Self {
140        Self {
141            from,
142            to,
143            field: field.into(),
144            details: Some(details.into()),
145            loss_type: FieldLossType::Omitted,
146        }
147    }
148}
149impl fmt::Display for CrosswalkError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            | Self::MissingRequiredField(field) => write!(f, "missing required field — {field}"),
153            | Self::TransformationFailed { field, reason } => {
154                write!(f, "failed to transform field '{field}' — {reason}")
155            }
156            | Self::BuildFailed(reason) => write!(f, "build failed — {reason}"),
157            | Self::ParseFailed(reason) => write!(f, "content parse failed — {reason}"),
158            | Self::SerializeFailed(reason) => write!(f, "output serialize failed — {reason}"),
159        }
160    }
161}
162impl From<String> for CrosswalkError {
163    fn from(err: String) -> Self {
164        CrosswalkError::BuildFailed(err)
165    }
166}
167impl fmt::Display for FieldLossType {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            | Self::NoEquivalent => write!(f, "no equivalent field"),
171            | Self::Approximated => write!(f, "approximated"),
172            | Self::Simplified => write!(f, "simplified"),
173            | Self::Omitted => write!(f, "omitted"),
174            | Self::BlockNotSelected => write!(f, "metadata block not selected"),
175        }
176    }
177}
178/// Convert between metadata schemas with tracking of warnings and data loss
179///
180/// Implementers provide error context and track lossy conversions via ConversionWarning.
181///
182/// # Example
183///
184/// ```ignore
185/// use acorn::schema::standard::crosswalk::Crosswalk;
186///
187/// let datacite_record = /* ... */;
188/// let (dcat_dataset, warnings) = datacite_record.crosswalk()?;
189/// for warning in warnings {
190///     eprintln!("Note: {}", warning);
191/// }
192/// ```
193pub trait Crosswalk<T>: Sized {
194    /// Convert self to target type, tracking data loss via warnings
195    ///
196    /// Returns `Ok((target, warnings))` on success, even if some fields were unmapped.
197    /// Returns `Err` only for missing required fields or transformation errors.
198    fn crosswalk(&self) -> Result<(T, Vec<ConversionWarning>), CrosswalkError>;
199}
200/// Extract normalized fields from a schema type
201///
202/// Implementers decompose their type into a `FieldMap`, enabling schema-agnostic transformations.
203pub trait SchemaExtractor {
204    /// Extract all fields into normalized form
205    fn extract_fields(&self) -> Fields;
206}
207/// Reconstruct a schema type from normalized fields
208///
209/// Implementers build their type from a `FieldMap`, enabling schema-agnostic transformation targets.
210pub trait SchemaBuilder: Sized {
211    /// Build from normalized fields
212    ///
213    /// Returns `Err` if required fields are missing or invalid.
214    fn build_from_fields(fields: &Fields) -> Result<Self, CrosswalkError>;
215}
216/// Execute a schema conversion using the pipeline
217///
218/// # Steps
219///
220/// 1. Extract all fields from source using SchemaExtractor
221/// 2. Look up FieldMapping for source→target conversion
222/// 3. Apply mapping rules to transform fields
223/// 4. Build target schema using SchemaBuilder
224/// 5. Return target and list of lossy conversions
225///
226/// # Errors
227///
228/// Returns error if:
229/// - Required fields are missing
230/// - Field transformation fails
231/// - Target schema cannot be built from fields
232pub fn convert<S, T>(source: &S, mapping: &FieldMapping) -> Result<(T, Vec<ConversionWarning>), CrosswalkError>
233where
234    S: SchemaExtractor,
235    T: SchemaBuilder,
236{
237    let source_fields = source.extract_fields();
238    let mut target_fields = Fields::new();
239    let warnings = Vec::new();
240    mapping.apply(&source_fields, &mut target_fields)?;
241    let target = T::build_from_fields(&target_fields)?;
242    Ok((target, warnings))
243}
244
245#[cfg(test)]
246mod tests;