ddex_builder/versions/
converter.rs

1use crate::presets::DdexVersion;
2use crate::versions::ConversionOptions;
3use indexmap::IndexMap;
4use quick_xml::events::{BytesEnd, BytesStart, Event};
5use quick_xml::{Reader, Writer};
6use std::io::Cursor;
7
8/// Result of version conversion operation
9#[derive(Debug, Clone)]
10pub enum ConversionResult {
11    /// Successful conversion
12    Success {
13        /// Converted XML content
14        xml: String,
15        /// Conversion report with warnings
16        report: ConversionReport,
17    },
18    /// Conversion failed
19    Failure {
20        /// Error description
21        error: String,
22        /// Partial conversion report
23        report: ConversionReport,
24    },
25}
26
27/// Report of conversion process between DDEX versions
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct ConversionReport {
30    /// Source DDEX version
31    pub from_version: DdexVersion,
32    /// Target DDEX version
33    pub to_version: DdexVersion,
34    /// List of conversion warnings
35    pub warnings: Vec<ConversionWarning>,
36    /// Number of elements successfully converted
37    pub elements_converted: usize,
38    /// Number of elements dropped (not supported in target version)
39    pub elements_dropped: usize,
40    /// Number of elements added (required in target version)
41    pub elements_added: usize,
42}
43
44/// Type of conversion warning
45#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
46pub enum ConversionWarningType {
47    /// Element was renamed in target version
48    ElementRenamed,
49    /// Element not supported in target version
50    ElementDropped,
51    /// Element added with default value
52    ElementAdded,
53    /// Validation rules changed between versions
54    ValidationChanged,
55    /// Namespace changes required
56    NamespaceChanged,
57    /// Format migration performed
58    FormatMigrated,
59}
60
61/// Warning generated during conversion
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct ConversionWarning {
64    /// Type of warning
65    pub warning_type: ConversionWarningType,
66    /// Warning description
67    pub message: String,
68    /// Element affected if applicable
69    pub element: Option<String>,
70}
71
72/// Handles conversion between different DDEX versions
73pub struct VersionConverter {
74    conversion_rules: IndexMap<(DdexVersion, DdexVersion), ConversionRules>,
75}
76
77#[derive(Debug, Clone)]
78struct ConversionRules {
79    element_mappings: IndexMap<String, ElementMapping>,
80    namespace_mapping: NamespaceMapping,
81    _field_migrations: Vec<FieldMigration>,
82    _validation_changes: Vec<ValidationChange>,
83}
84
85#[derive(Debug, Clone)]
86#[allow(dead_code)]
87enum ElementMapping {
88    Direct(String),
89    Renamed(String),
90    /// These variants may be used in future versions
91    Split {
92        into: Vec<String>,
93        splitter: fn(&str) -> Vec<String>,
94    },
95    /// These variants may be used in future versions
96    Merge {
97        from: Vec<String>,
98        merger: fn(Vec<&str>) -> String,
99    },
100    Deprecated {
101        replacement: Option<String>,
102        warning: String,
103    },
104    New {
105        default_value: Option<String>,
106    },
107}
108
109#[derive(Debug, Clone)]
110#[allow(dead_code)]
111struct NamespaceMapping {
112    from: String,
113    to: String,
114    schema_version_from: String,
115    schema_version_to: String,
116}
117
118#[allow(dead_code)]
119#[derive(Debug, Clone)]
120struct FieldMigration {
121    element: String,
122    field: String,
123    migration_type: MigrationType,
124}
125
126#[allow(dead_code)]
127#[derive(Debug, Clone)]
128enum MigrationType {
129    FormatChange {
130        from_pattern: String,
131        to_pattern: String,
132    },
133    ValueMapping(IndexMap<String, String>),
134    ValidationChange {
135        old_rules: Vec<String>,
136        new_rules: Vec<String>,
137    },
138}
139
140#[allow(dead_code)]
141#[derive(Debug, Clone)]
142struct ValidationChange {
143    element: String,
144    change_type: ValidationChangeType,
145}
146
147#[allow(dead_code)]
148#[derive(Debug, Clone)]
149enum ValidationChangeType {
150    RequiredAdded(String),
151    RequiredRemoved(String),
152    OptionalAdded(String),
153    OptionalRemoved(String),
154    FormatChanged {
155        field: String,
156        old_format: String,
157        new_format: String,
158    },
159}
160
161impl VersionConverter {
162    /// Create a new version converter
163    pub fn new() -> Self {
164        let mut converter = Self {
165            conversion_rules: IndexMap::new(),
166        };
167        converter.initialize_conversion_rules();
168        converter
169    }
170
171    fn initialize_conversion_rules(&mut self) {
172        self.add_382_to_42_rules();
173        self.add_42_to_43_rules();
174        self.add_43_to_42_rules();
175        self.add_42_to_382_rules();
176    }
177
178    fn add_382_to_42_rules(&mut self) {
179        let mut element_mappings = IndexMap::new();
180
181        element_mappings.insert(
182            "SoundRecording".to_string(),
183            ElementMapping::Direct("SoundRecording".to_string()),
184        );
185
186        element_mappings.insert(
187            "TechnicalSoundRecordingDetails".to_string(),
188            ElementMapping::Renamed("TechnicalDetails".to_string()),
189        );
190
191        element_mappings.insert(
192            "CommercialModelType".to_string(),
193            ElementMapping::New {
194                default_value: Some("SubscriptionModel".to_string()),
195            },
196        );
197
198        element_mappings.insert(
199            "Territory".to_string(),
200            ElementMapping::Direct("Territory".to_string()),
201        );
202
203        let rules = ConversionRules {
204            element_mappings,
205            namespace_mapping: NamespaceMapping {
206                from: "http://ddex.net/xml/ern/382".to_string(),
207                to: "http://ddex.net/xml/ern/42".to_string(),
208                schema_version_from: "ern/382".to_string(),
209                schema_version_to: "ern/42".to_string(),
210            },
211            _field_migrations: vec![FieldMigration {
212                element: "Duration".to_string(),
213                field: "value".to_string(),
214                migration_type: MigrationType::FormatChange {
215                    from_pattern: r"^PT\d+S$".to_string(),
216                    to_pattern: r"^PT(\d+H)?(\d+M)?\d+(\.\d+)?S$".to_string(),
217                },
218            }],
219            _validation_changes: vec![
220                ValidationChange {
221                    element: "SoundRecording".to_string(),
222                    change_type: ValidationChangeType::OptionalAdded("HashSum".to_string()),
223                },
224                ValidationChange {
225                    element: "TechnicalDetails".to_string(),
226                    change_type: ValidationChangeType::OptionalAdded("BitRate".to_string()),
227                },
228            ],
229        };
230
231        self.conversion_rules
232            .insert((DdexVersion::Ern382, DdexVersion::Ern42), rules);
233    }
234
235    fn add_42_to_43_rules(&mut self) {
236        let mut element_mappings = IndexMap::new();
237
238        element_mappings.insert(
239            "SoundRecording".to_string(),
240            ElementMapping::Direct("SoundRecording".to_string()),
241        );
242
243        element_mappings.insert(
244            "VideoResource".to_string(),
245            ElementMapping::New {
246                default_value: None,
247            },
248        );
249
250        element_mappings.insert(
251            "HashSum".to_string(),
252            ElementMapping::Direct("HashSum".to_string()),
253        );
254
255        let rules = ConversionRules {
256            element_mappings,
257            namespace_mapping: NamespaceMapping {
258                from: "http://ddex.net/xml/ern/42".to_string(),
259                to: "http://ddex.net/xml/ern/43".to_string(),
260                schema_version_from: "ern/42".to_string(),
261                schema_version_to: "ern/43".to_string(),
262            },
263            _field_migrations: vec![FieldMigration {
264                element: "ISRC".to_string(),
265                field: "value".to_string(),
266                migration_type: MigrationType::ValidationChange {
267                    old_rules: vec![r"^[A-Z]{2}[A-Z0-9]{3}\d{7}$".to_string()],
268                    new_rules: vec![r"^[A-Z]{2}-?[A-Z0-9]{3}-?\d{2}-?\d{5}$".to_string()],
269                },
270            }],
271            _validation_changes: vec![
272                ValidationChange {
273                    element: "SoundRecording".to_string(),
274                    change_type: ValidationChangeType::RequiredAdded("ProprietaryId".to_string()),
275                },
276                ValidationChange {
277                    element: "VideoResource".to_string(),
278                    change_type: ValidationChangeType::OptionalAdded("Duration".to_string()),
279                },
280            ],
281        };
282
283        self.conversion_rules
284            .insert((DdexVersion::Ern42, DdexVersion::Ern43), rules);
285    }
286
287    fn add_43_to_42_rules(&mut self) {
288        let mut element_mappings = IndexMap::new();
289
290        element_mappings.insert(
291            "SoundRecording".to_string(),
292            ElementMapping::Direct("SoundRecording".to_string()),
293        );
294
295        element_mappings.insert(
296            "VideoResource".to_string(),
297            ElementMapping::Deprecated {
298                replacement: None,
299                warning: "VideoResource not supported in ERN 4.2, will be omitted".to_string(),
300            },
301        );
302
303        element_mappings.insert(
304            "HashSum".to_string(),
305            ElementMapping::Direct("HashSum".to_string()),
306        );
307
308        let rules = ConversionRules {
309            element_mappings,
310            namespace_mapping: NamespaceMapping {
311                from: "http://ddex.net/xml/ern/43".to_string(),
312                to: "http://ddex.net/xml/ern/42".to_string(),
313                schema_version_from: "ern/43".to_string(),
314                schema_version_to: "ern/42".to_string(),
315            },
316            _field_migrations: vec![],
317            _validation_changes: vec![ValidationChange {
318                element: "SoundRecording".to_string(),
319                change_type: ValidationChangeType::RequiredRemoved("ProprietaryId".to_string()),
320            }],
321        };
322
323        self.conversion_rules
324            .insert((DdexVersion::Ern43, DdexVersion::Ern42), rules);
325    }
326
327    fn add_42_to_382_rules(&mut self) {
328        let mut element_mappings = IndexMap::new();
329
330        element_mappings.insert(
331            "SoundRecording".to_string(),
332            ElementMapping::Direct("SoundRecording".to_string()),
333        );
334
335        element_mappings.insert(
336            "TechnicalDetails".to_string(),
337            ElementMapping::Renamed("TechnicalSoundRecordingDetails".to_string()),
338        );
339
340        element_mappings.insert(
341            "CommercialModelType".to_string(),
342            ElementMapping::Deprecated {
343                replacement: None,
344                warning: "CommercialModelType not supported in ERN 3.8.2, will be omitted"
345                    .to_string(),
346            },
347        );
348
349        let rules = ConversionRules {
350            element_mappings,
351            namespace_mapping: NamespaceMapping {
352                from: "http://ddex.net/xml/ern/42".to_string(),
353                to: "http://ddex.net/xml/ern/382".to_string(),
354                schema_version_from: "ern/42".to_string(),
355                schema_version_to: "ern/382".to_string(),
356            },
357            _field_migrations: vec![],
358            _validation_changes: vec![ValidationChange {
359                element: "SoundRecording".to_string(),
360                change_type: ValidationChangeType::OptionalRemoved("HashSum".to_string()),
361            }],
362        };
363
364        self.conversion_rules
365            .insert((DdexVersion::Ern42, DdexVersion::Ern382), rules);
366    }
367
368    /// Convert DDEX content between versions
369    ///
370    /// # Arguments
371    /// * `xml_content` - Source XML content
372    /// * `from_version` - Source DDEX version
373    /// * `to_version` - Target DDEX version
374    /// * `options` - Optional conversion configuration
375    pub fn convert(
376        &self,
377        xml_content: &str,
378        from_version: DdexVersion,
379        to_version: DdexVersion,
380        options: Option<ConversionOptions>,
381    ) -> ConversionResult {
382        let options = options.unwrap_or_default();
383        let mut report = ConversionReport {
384            from_version,
385            to_version,
386            warnings: Vec::new(),
387            elements_converted: 0,
388            elements_dropped: 0,
389            elements_added: 0,
390        };
391
392        if from_version == to_version {
393            return ConversionResult::Success {
394                xml: xml_content.to_string(),
395                report,
396            };
397        }
398
399        let conversion_path = self.find_conversion_path(from_version, to_version);
400        match conversion_path {
401            Some(path) => self.execute_conversion_path(xml_content, &path, options, &mut report),
402            None => ConversionResult::Failure {
403                error: format!(
404                    "No conversion path found from {:?} to {:?}",
405                    from_version, to_version
406                ),
407                report,
408            },
409        }
410    }
411
412    fn find_conversion_path(&self, from: DdexVersion, to: DdexVersion) -> Option<Vec<DdexVersion>> {
413        if let Some(_) = self.conversion_rules.get(&(from, to)) {
414            return Some(vec![from, to]);
415        }
416
417        // Check for multi-step conversions
418        match (from, to) {
419            (DdexVersion::Ern382, DdexVersion::Ern43) => Some(vec![
420                DdexVersion::Ern382,
421                DdexVersion::Ern42,
422                DdexVersion::Ern43,
423            ]),
424            (DdexVersion::Ern43, DdexVersion::Ern382) => Some(vec![
425                DdexVersion::Ern43,
426                DdexVersion::Ern42,
427                DdexVersion::Ern382,
428            ]),
429            _ => None,
430        }
431    }
432
433    fn execute_conversion_path(
434        &self,
435        xml_content: &str,
436        path: &[DdexVersion],
437        options: ConversionOptions,
438        report: &mut ConversionReport,
439    ) -> ConversionResult {
440        let mut current_xml = xml_content.to_string();
441
442        for window in path.windows(2) {
443            let from = window[0];
444            let to = window[1];
445
446            match self.convert_single_step(&current_xml, from, to, &options, report) {
447                ConversionResult::Success {
448                    xml,
449                    report: step_report,
450                } => {
451                    current_xml = xml;
452                    report.warnings.extend(step_report.warnings);
453                    report.elements_converted += step_report.elements_converted;
454                    report.elements_dropped += step_report.elements_dropped;
455                    report.elements_added += step_report.elements_added;
456                }
457                ConversionResult::Failure { error, .. } => {
458                    return ConversionResult::Failure {
459                        error,
460                        report: report.clone(),
461                    };
462                }
463            }
464        }
465
466        ConversionResult::Success {
467            xml: current_xml,
468            report: report.clone(),
469        }
470    }
471
472    fn convert_single_step(
473        &self,
474        xml_content: &str,
475        from: DdexVersion,
476        to: DdexVersion,
477        options: &ConversionOptions,
478        report: &mut ConversionReport,
479    ) -> ConversionResult {
480        let rules = match self.conversion_rules.get(&(from, to)) {
481            Some(rules) => rules,
482            None => {
483                return ConversionResult::Failure {
484                    error: format!("No direct conversion rules from {:?} to {:?}", from, to),
485                    report: report.clone(),
486                }
487            }
488        };
489
490        match self.transform_xml(xml_content, rules, options) {
491            Ok((transformed_xml, conversion_warnings)) => {
492                report.warnings.extend(conversion_warnings);
493                ConversionResult::Success {
494                    xml: transformed_xml,
495                    report: report.clone(),
496                }
497            }
498            Err(error) => ConversionResult::Failure {
499                error: error.to_string(),
500                report: report.clone(),
501            },
502        }
503    }
504
505    fn transform_xml(
506        &self,
507        xml_content: &str,
508        rules: &ConversionRules,
509        options: &ConversionOptions,
510    ) -> Result<(String, Vec<ConversionWarning>), Box<dyn std::error::Error>> {
511        let mut reader = Reader::from_str(xml_content);
512        let mut writer = Writer::new(Cursor::new(Vec::new()));
513        let mut warnings = Vec::new();
514        let mut buf = Vec::new();
515        let mut elements_stack = Vec::new();
516        let mut skip_element = false;
517        let mut skip_depth = 0;
518
519        loop {
520            match reader.read_event_into(&mut buf) {
521                Ok(Event::Start(ref e)) => {
522                    let element_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
523                    elements_stack.push(element_name.clone());
524
525                    if skip_element {
526                        skip_depth += 1;
527                        continue;
528                    }
529
530                    match rules.element_mappings.get(&element_name) {
531                        Some(ElementMapping::Direct(new_name)) => {
532                            let mut new_element = BytesStart::new(new_name);
533                            for attr in e.attributes() {
534                                if let Ok(attr) = attr {
535                                    new_element.push_attribute(attr);
536                                }
537                            }
538                            self.update_namespace_attributes(
539                                &mut new_element,
540                                &rules.namespace_mapping,
541                            );
542                            writer.write_event(Event::Start(new_element))?;
543                        }
544                        Some(ElementMapping::Renamed(new_name)) => {
545                            let mut new_element = BytesStart::new(new_name);
546                            for attr in e.attributes() {
547                                if let Ok(attr) = attr {
548                                    new_element.push_attribute(attr);
549                                }
550                            }
551                            self.update_namespace_attributes(
552                                &mut new_element,
553                                &rules.namespace_mapping,
554                            );
555                            writer.write_event(Event::Start(new_element))?;
556                            warnings.push(ConversionWarning {
557                                warning_type: ConversionWarningType::ElementRenamed,
558                                message: format!(
559                                    "Element '{}' renamed to '{}'",
560                                    element_name, new_name
561                                ),
562                                element: Some(element_name),
563                            });
564                        }
565                        Some(ElementMapping::Deprecated {
566                            replacement: _,
567                            warning,
568                        }) => {
569                            skip_element = true;
570                            skip_depth = 1;
571                            warnings.push(ConversionWarning {
572                                warning_type: ConversionWarningType::ElementDropped,
573                                message: warning.clone(),
574                                element: Some(element_name),
575                            });
576                        }
577                        Some(ElementMapping::New { .. }) => {
578                            writer.write_event(Event::Start(e.clone()))?;
579                        }
580                        _ => {
581                            let mut cloned_element = e.clone();
582                            self.update_namespace_attributes(
583                                &mut cloned_element,
584                                &rules.namespace_mapping,
585                            );
586                            writer.write_event(Event::Start(cloned_element))?;
587                        }
588                    }
589                }
590                Ok(Event::End(ref e)) => {
591                    elements_stack.pop();
592
593                    if skip_element {
594                        skip_depth -= 1;
595                        if skip_depth == 0 {
596                            skip_element = false;
597                        }
598                        continue;
599                    }
600
601                    let element_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
602                    match rules.element_mappings.get(&element_name) {
603                        Some(ElementMapping::Direct(new_name)) => {
604                            writer.write_event(Event::End(BytesEnd::new(new_name)))?;
605                        }
606                        Some(ElementMapping::Renamed(new_name)) => {
607                            writer.write_event(Event::End(BytesEnd::new(new_name)))?;
608                        }
609                        Some(ElementMapping::Deprecated { .. }) => {
610                            // Skip deprecated elements
611                        }
612                        _ => {
613                            writer.write_event(Event::End(e.clone()))?;
614                        }
615                    }
616                }
617                Ok(Event::Text(ref e)) => {
618                    if !skip_element {
619                        writer.write_event(Event::Text(e.clone()))?;
620                    }
621                }
622                Ok(Event::Comment(ref e)) => {
623                    if !skip_element && options.preserve_comments {
624                        writer.write_event(Event::Comment(e.clone()))?;
625                    }
626                }
627                Ok(Event::CData(ref e)) => {
628                    if !skip_element {
629                        writer.write_event(Event::CData(e.clone()))?;
630                    }
631                }
632                Ok(Event::Decl(ref e)) => {
633                    writer.write_event(Event::Decl(e.clone()))?;
634                }
635                Ok(Event::PI(ref e)) => {
636                    if !skip_element {
637                        writer.write_event(Event::PI(e.clone()))?;
638                    }
639                }
640                Ok(Event::DocType(ref e)) => {
641                    writer.write_event(Event::DocType(e.clone()))?;
642                }
643                Ok(Event::Empty(ref e)) => {
644                    if !skip_element {
645                        writer.write_event(Event::Empty(e.clone()))?;
646                    }
647                }
648                Ok(Event::Eof) => break,
649                Err(e) => return Err(format!("Error parsing XML: {}", e).into()),
650            }
651            buf.clear();
652        }
653
654        let result = writer.into_inner().into_inner();
655        let transformed_xml = String::from_utf8(result)?;
656        Ok((transformed_xml, warnings))
657    }
658
659    fn update_namespace_attributes(
660        &self,
661        element: &mut BytesStart,
662        namespace_mapping: &NamespaceMapping,
663    ) {
664        // Update xmlns attributes to new namespace
665        let mut attrs_to_update = Vec::new();
666
667        for (i, attr_result) in element.attributes().enumerate() {
668            if let Ok(attr) = attr_result {
669                let key = String::from_utf8_lossy(attr.key.as_ref());
670                let value = String::from_utf8_lossy(&attr.value);
671
672                if key == "xmlns" && value == namespace_mapping.from {
673                    attrs_to_update.push((i, "xmlns".to_string(), namespace_mapping.to.clone()));
674                } else if key.starts_with("xmlns:") && value == namespace_mapping.from {
675                    attrs_to_update.push((i, key.to_string(), namespace_mapping.to.clone()));
676                }
677            }
678        }
679
680        // Apply namespace updates
681        for (_, key, new_value) in attrs_to_update {
682            element.extend_attributes(std::iter::once((key.as_str(), new_value.as_str())));
683        }
684    }
685
686    /// Get list of supported version conversions
687    pub fn get_supported_conversions(&self) -> Vec<(DdexVersion, DdexVersion)> {
688        self.conversion_rules.keys().cloned().collect()
689    }
690
691    /// Check if conversion between versions is supported
692    pub fn can_convert(&self, from: DdexVersion, to: DdexVersion) -> bool {
693        self.find_conversion_path(from, to).is_some()
694    }
695}
696
697impl Default for VersionConverter {
698    fn default() -> Self {
699        Self::new()
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn test_converter_initialization() {
709        let converter = VersionConverter::new();
710        assert!(!converter.conversion_rules.is_empty());
711    }
712
713    #[test]
714    fn test_direct_conversion_path() {
715        let converter = VersionConverter::new();
716        let path = converter.find_conversion_path(DdexVersion::Ern382, DdexVersion::Ern42);
717        assert_eq!(path, Some(vec![DdexVersion::Ern382, DdexVersion::Ern42]));
718    }
719
720    #[test]
721    fn test_multi_step_conversion_path() {
722        let converter = VersionConverter::new();
723        let path = converter.find_conversion_path(DdexVersion::Ern382, DdexVersion::Ern43);
724        assert_eq!(
725            path,
726            Some(vec![
727                DdexVersion::Ern382,
728                DdexVersion::Ern42,
729                DdexVersion::Ern43
730            ])
731        );
732    }
733
734    #[test]
735    fn test_same_version_conversion() {
736        let converter = VersionConverter::new();
737        let xml = r#"<?xml version="1.0" encoding="UTF-8"?><test>content</test>"#;
738        let result = converter.convert(xml, DdexVersion::Ern42, DdexVersion::Ern42, None);
739
740        match result {
741            ConversionResult::Success {
742                xml: result_xml, ..
743            } => {
744                assert_eq!(result_xml, xml);
745            }
746            _ => panic!("Expected successful conversion for same version"),
747        }
748    }
749
750    #[test]
751    fn test_supported_conversions() {
752        let converter = VersionConverter::new();
753        let conversions = converter.get_supported_conversions();
754        assert!(conversions.contains(&(DdexVersion::Ern382, DdexVersion::Ern42)));
755        assert!(conversions.contains(&(DdexVersion::Ern42, DdexVersion::Ern43)));
756        assert!(conversions.contains(&(DdexVersion::Ern43, DdexVersion::Ern42)));
757        assert!(conversions.contains(&(DdexVersion::Ern42, DdexVersion::Ern382)));
758    }
759}