ddex_builder/diff/
types.rs

1//! Type definitions for DDEX semantic diffing
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// Result of a semantic diff operation
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub enum DiffResult {
10    /// No changes detected
11    Unchanged,
12    /// Content was added
13    Added,
14    /// Content was modified  
15    Modified,
16    /// Content was removed
17    Removed,
18    /// Element was moved
19    Moved {
20        /// Original path
21        from: DiffPath,
22        /// New path
23        to: DiffPath,
24    },
25}
26
27/// Complete set of changes between two DDEX documents
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ChangeSet {
30    /// Individual semantic changes
31    pub changes: Vec<SemanticChange>,
32
33    /// Summary statistics
34    pub summary: ChangeSummary,
35
36    /// Additional metadata about the diff
37    pub metadata: IndexMap<String, String>,
38
39    /// Timestamp when diff was performed
40    pub timestamp: chrono::DateTime<chrono::Utc>,
41}
42
43impl ChangeSet {
44    /// Create a new empty changeset
45    pub fn new() -> Self {
46        Self {
47            changes: Vec::new(),
48            summary: ChangeSummary::default(),
49            metadata: IndexMap::new(),
50            timestamp: chrono::Utc::now(),
51        }
52    }
53
54    /// Add a semantic change to the changeset
55    pub fn add_change(&mut self, change: SemanticChange) {
56        // Update summary statistics
57        match change.change_type {
58            ChangeType::ElementAdded | ChangeType::AttributeAdded => {
59                self.summary.additions += 1;
60            }
61            ChangeType::ElementRemoved | ChangeType::AttributeRemoved => {
62                self.summary.deletions += 1;
63            }
64            ChangeType::ElementModified
65            | ChangeType::AttributeModified
66            | ChangeType::TextModified
67            | ChangeType::ElementRenamed => {
68                self.summary.modifications += 1;
69            }
70            ChangeType::ElementMoved => {
71                self.summary.moves += 1;
72            }
73        }
74
75        if change.is_critical {
76            self.summary.critical_changes += 1;
77        }
78
79        self.changes.push(change);
80        self.summary.total_changes = self.changes.len();
81    }
82
83    /// Check if there are any changes
84    pub fn has_changes(&self) -> bool {
85        !self.changes.is_empty()
86    }
87
88    /// Get changes by criticality
89    pub fn critical_changes(&self) -> Vec<&SemanticChange> {
90        self.changes.iter().filter(|c| c.is_critical).collect()
91    }
92
93    /// Get changes by type
94    pub fn changes_by_type(&self, change_type: ChangeType) -> Vec<&SemanticChange> {
95        self.changes
96            .iter()
97            .filter(|c| c.change_type == change_type)
98            .collect()
99    }
100
101    /// Get overall impact level
102    pub fn impact_level(&self) -> ImpactLevel {
103        if self.summary.critical_changes > 0 {
104            ImpactLevel::High
105        } else if self.summary.total_changes > 10 {
106            ImpactLevel::Medium
107        } else if self.summary.total_changes > 0 {
108            ImpactLevel::Low
109        } else {
110            ImpactLevel::None
111        }
112    }
113}
114
115impl Default for ChangeSet {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121/// A single semantic change in a DDEX document
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct SemanticChange {
124    /// Path to the changed element/attribute
125    pub path: DiffPath,
126
127    /// Type of change
128    pub change_type: ChangeType,
129
130    /// Previous value (if any)
131    pub old_value: Option<String>,
132
133    /// New value (if any)
134    pub new_value: Option<String>,
135
136    /// Whether this change is business-critical
137    pub is_critical: bool,
138
139    /// Human-readable description of the change
140    pub description: String,
141}
142
143/// Path to a specific location in a DDEX document
144#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
145pub struct DiffPath {
146    /// Path segments (element names, attribute names)
147    pub segments: Vec<PathSegment>,
148}
149
150impl DiffPath {
151    /// Create root path
152    pub fn root() -> Self {
153        Self {
154            segments: Vec::new(),
155        }
156    }
157
158    /// Create path with a single element
159    pub fn element(name: &str) -> Self {
160        Self {
161            segments: vec![PathSegment::Element(name.to_string())],
162        }
163    }
164
165    /// Add an element to the path
166    pub fn with_element(&self, name: &str) -> Self {
167        let mut segments = self.segments.clone();
168        segments.push(PathSegment::Element(name.to_string()));
169        Self { segments }
170    }
171
172    /// Add an attribute to the path
173    pub fn with_attribute(&self, name: &str) -> Self {
174        let mut segments = self.segments.clone();
175        segments.push(PathSegment::Attribute(name.to_string()));
176        Self { segments }
177    }
178
179    /// Add text content to the path
180    pub fn with_text(&self) -> Self {
181        let mut segments = self.segments.clone();
182        segments.push(PathSegment::Text);
183        Self { segments }
184    }
185
186    /// Add an index to the path for array elements
187    pub fn with_index(&self, index: usize) -> Self {
188        let mut segments = self.segments.clone();
189        segments.push(PathSegment::Index(index));
190        Self { segments }
191    }
192
193    /// Get the path as a slash-separated string
194    pub fn to_string(&self) -> String {
195        if self.segments.is_empty() {
196            return "/".to_string();
197        }
198
199        let mut path = String::new();
200        for segment in &self.segments {
201            path.push('/');
202            match segment {
203                PathSegment::Element(name) => path.push_str(name),
204                PathSegment::Attribute(name) => {
205                    path.push('@');
206                    path.push_str(name);
207                }
208                PathSegment::Text => path.push_str("#text"),
209                PathSegment::Index(idx) => path.push_str(&format!("[{}]", idx)),
210            }
211        }
212        path
213    }
214}
215
216impl fmt::Display for DiffPath {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        write!(f, "{}", self.to_string())
219    }
220}
221
222/// Individual segment of a diff path
223#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
224pub enum PathSegment {
225    /// XML element name
226    Element(String),
227    /// XML attribute name
228    Attribute(String),
229    /// Text content
230    Text,
231    /// Array index for repeated elements
232    Index(usize),
233}
234
235/// Type of semantic change
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237pub enum ChangeType {
238    /// Element was added
239    ElementAdded,
240    /// Element was removed
241    ElementRemoved,
242    /// Element was modified
243    ElementModified,
244    /// Element was renamed
245    ElementRenamed,
246    /// Element was moved
247    ElementMoved,
248    /// Attribute was added
249    AttributeAdded,
250    /// Attribute was removed
251    AttributeRemoved,
252    /// Attribute was modified
253    AttributeModified,
254    /// Text content was modified
255    TextModified,
256}
257
258impl fmt::Display for ChangeType {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        let s = match self {
261            ChangeType::ElementAdded => "Element Added",
262            ChangeType::ElementRemoved => "Element Removed",
263            ChangeType::ElementModified => "Element Modified",
264            ChangeType::ElementRenamed => "Element Renamed",
265            ChangeType::ElementMoved => "Element Moved",
266            ChangeType::AttributeAdded => "Attribute Added",
267            ChangeType::AttributeRemoved => "Attribute Removed",
268            ChangeType::AttributeModified => "Attribute Modified",
269            ChangeType::TextModified => "Text Modified",
270        };
271        write!(f, "{}", s)
272    }
273}
274
275/// Summary of changes in a changeset
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277pub struct ChangeSummary {
278    /// Total number of changes
279    pub total_changes: usize,
280    /// Number of additions
281    pub additions: usize,
282    /// Number of deletions
283    pub deletions: usize,
284    /// Number of modifications
285    pub modifications: usize,
286    /// Number of moves
287    pub moves: usize,
288    /// Number of critical changes
289    pub critical_changes: usize,
290}
291
292impl ChangeSummary {
293    /// Check if there are any changes
294    pub fn has_changes(&self) -> bool {
295        self.total_changes > 0
296    }
297
298    /// Get a brief summary string
299    pub fn summary_string(&self) -> String {
300        if !self.has_changes() {
301            return "No changes".to_string();
302        }
303
304        let mut parts = Vec::new();
305
306        if self.additions > 0 {
307            parts.push(format!("{} added", self.additions));
308        }
309        if self.deletions > 0 {
310            parts.push(format!("{} deleted", self.deletions));
311        }
312        if self.modifications > 0 {
313            parts.push(format!("{} modified", self.modifications));
314        }
315        if self.moves > 0 {
316            parts.push(format!("{} moved", self.moves));
317        }
318
319        let summary = parts.join(", ");
320
321        if self.critical_changes > 0 {
322            format!("{} ({} critical)", summary, self.critical_changes)
323        } else {
324            summary
325        }
326    }
327}
328
329/// Impact level of changes
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331pub enum ImpactLevel {
332    /// No changes
333    None,
334    /// Low impact changes (formatting, minor additions)
335    Low,
336    /// Medium impact changes (significant additions/modifications)
337    Medium,
338    /// High impact changes (critical business fields affected)
339    High,
340}
341
342impl fmt::Display for ImpactLevel {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        let s = match self {
345            ImpactLevel::None => "None",
346            ImpactLevel::Low => "Low",
347            ImpactLevel::Medium => "Medium",
348            ImpactLevel::High => "High",
349        };
350        write!(f, "{}", s)
351    }
352}
353
354/// Context information for understanding a change
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct ChangeContext {
357    /// Related DDEX entity (Release, Resource, Deal, etc.)
358    pub entity_type: Option<String>,
359
360    /// Entity identifier if available
361    pub entity_id: Option<String>,
362
363    /// Business context (pricing, territory, rights, etc.)
364    pub business_context: Option<String>,
365
366    /// Technical context (schema version, etc.)
367    pub technical_context: IndexMap<String, String>,
368}
369
370/// Configuration for what constitutes a significant change
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct ChangeSignificance {
373    /// Fields that are considered critical
374    pub critical_fields: Vec<String>,
375
376    /// Fields that should be ignored
377    pub ignored_fields: Vec<String>,
378
379    /// Numeric tolerance for comparisons
380    pub numeric_tolerance: f64,
381
382    /// Whether to ignore order changes
383    pub ignore_order: bool,
384}
385
386impl Default for ChangeSignificance {
387    fn default() -> Self {
388        Self {
389            critical_fields: vec![
390                "CommercialModelType".to_string(),
391                "TerritoryCode".to_string(),
392                "Price".to_string(),
393                "ValidityPeriod".to_string(),
394                "ReleaseDate".to_string(),
395                "UPC".to_string(),
396                "ISRC".to_string(),
397            ],
398            ignored_fields: vec![
399                "MessageId".to_string(),
400                "MessageCreatedDateTime".to_string(),
401            ],
402            numeric_tolerance: 0.01,
403            ignore_order: true,
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_diff_path() {
414        let path = DiffPath::root()
415            .with_element("Release")
416            .with_attribute("ReleaseId");
417
418        assert_eq!(path.to_string(), "/Release/@ReleaseId");
419    }
420
421    #[test]
422    fn test_changeset() {
423        let mut changeset = ChangeSet::new();
424
425        changeset.add_change(SemanticChange {
426            path: DiffPath::element("Test"),
427            change_type: ChangeType::ElementAdded,
428            old_value: None,
429            new_value: Some("new".to_string()),
430            is_critical: true,
431            description: "Test change".to_string(),
432        });
433
434        assert!(changeset.has_changes());
435        assert_eq!(changeset.summary.total_changes, 1);
436        assert_eq!(changeset.summary.critical_changes, 1);
437        assert_eq!(changeset.impact_level(), ImpactLevel::High);
438    }
439
440    #[test]
441    fn test_change_summary() {
442        let mut summary = ChangeSummary::default();
443        assert!(!summary.has_changes());
444        assert_eq!(summary.summary_string(), "No changes");
445
446        summary.additions = 2;
447        summary.modifications = 1;
448        summary.critical_changes = 1;
449        summary.total_changes = 3;
450
451        assert!(summary.has_changes());
452        let summary_str = summary.summary_string();
453        assert!(summary_str.contains("2 added"));
454        assert!(summary_str.contains("1 modified"));
455        assert!(summary_str.contains("1 critical"));
456    }
457}