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;