ddex_builder/diff/
types.rs

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