ddex_builder/diff/
formatter.rs

1//! Formatters for DDEX diff output in various formats
2
3use super::types::{ChangeSet, SemanticChange, ChangeType};
4use crate::error::BuildError;
5use serde_json::json;
6use indexmap::IndexMap;
7use std::fmt::Write;
8
9/// Formatter for diff output in various formats
10pub struct DiffFormatter;
11
12impl DiffFormatter {
13    /// Format changeset as human-readable summary
14    pub fn format_summary(changeset: &ChangeSet) -> String {
15        let mut output = String::new();
16        
17        // Header
18        writeln!(output, "DDEX Semantic Diff Summary").unwrap();
19        writeln!(output, "==========================").unwrap();
20        writeln!(output, "Timestamp: {}", changeset.timestamp.format("%Y-%m-%d %H:%M:%S UTC")).unwrap();
21        writeln!(output, "Impact Level: {}", changeset.impact_level()).unwrap();
22        writeln!(output, "Changes: {}", changeset.summary.summary_string()).unwrap();
23        writeln!(output).unwrap();
24        
25        if !changeset.has_changes() {
26            writeln!(output, "✅ No semantic changes detected").unwrap();
27            return output;
28        }
29        
30        // Critical changes first
31        let critical_changes = changeset.critical_changes();
32        if !critical_changes.is_empty() {
33            writeln!(output, "🚨 Critical Changes ({}):", critical_changes.len()).unwrap();
34            for change in critical_changes {
35                writeln!(output, "  {} {}", 
36                    Self::change_type_icon(change.change_type), 
37                    change.description).unwrap();
38                writeln!(output, "    Path: {}", change.path).unwrap();
39                if let (Some(old), Some(new)) = (&change.old_value, &change.new_value) {
40                    writeln!(output, "    Change: '{}' → '{}'", old, new).unwrap();
41                }
42                writeln!(output).unwrap();
43            }
44        }
45        
46        // Group remaining changes by type
47        let mut changes_by_type: IndexMap<ChangeType, Vec<&SemanticChange>> = IndexMap::new();
48        for change in &changeset.changes {
49            if !change.is_critical {
50                changes_by_type.entry(change.change_type).or_default().push(change);
51            }
52        }
53        
54        for (change_type, changes) in changes_by_type {
55            if !changes.is_empty() {
56                writeln!(output, "{} {} ({}):", 
57                    Self::change_type_icon(change_type), 
58                    change_type, 
59                    changes.len()).unwrap();
60                
61                for change in changes {
62                    writeln!(output, "  • {} ({})", change.description, change.path).unwrap();
63                }
64                writeln!(output).unwrap();
65            }
66        }
67        
68        // Metadata
69        if !changeset.metadata.is_empty() {
70            writeln!(output, "Metadata:").unwrap();
71            for (key, value) in &changeset.metadata {
72                writeln!(output, "  {}: {}", key, value).unwrap();
73            }
74        }
75        
76        output
77    }
78    
79    /// Format changeset as detailed report
80    pub fn format_detailed(changeset: &ChangeSet) -> String {
81        let mut output = String::new();
82        
83        writeln!(output, "DDEX Semantic Diff - Detailed Report").unwrap();
84        writeln!(output, "====================================").unwrap();
85        writeln!(output, "Generated: {}", changeset.timestamp.format("%Y-%m-%d %H:%M:%S UTC")).unwrap();
86        writeln!(output).unwrap();
87        
88        // Statistics
89        writeln!(output, "Statistics:").unwrap();
90        writeln!(output, "  Total Changes: {}", changeset.summary.total_changes).unwrap();
91        writeln!(output, "  Additions: {}", changeset.summary.additions).unwrap();
92        writeln!(output, "  Deletions: {}", changeset.summary.deletions).unwrap();
93        writeln!(output, "  Modifications: {}", changeset.summary.modifications).unwrap();
94        writeln!(output, "  Moves: {}", changeset.summary.moves).unwrap();
95        writeln!(output, "  Critical: {}", changeset.summary.critical_changes).unwrap();
96        writeln!(output, "  Impact: {}", changeset.impact_level()).unwrap();
97        writeln!(output).unwrap();
98        
99        if !changeset.has_changes() {
100            writeln!(output, "No changes detected.").unwrap();
101            return output;
102        }
103        
104        // Detailed change list
105        writeln!(output, "Detailed Changes:").unwrap();
106        writeln!(output, "-----------------").unwrap();
107        
108        for (i, change) in changeset.changes.iter().enumerate() {
109            writeln!(output, "{}. {} {}", 
110                i + 1, 
111                Self::change_type_icon(change.change_type), 
112                change.description).unwrap();
113            writeln!(output, "   Type: {}", change.change_type).unwrap();
114            writeln!(output, "   Path: {}", change.path).unwrap();
115            writeln!(output, "   Critical: {}", if change.is_critical { "Yes" } else { "No" }).unwrap();
116            
117            match (&change.old_value, &change.new_value) {
118                (Some(old), Some(new)) => {
119                    writeln!(output, "   Old Value: {}", Self::truncate_value(old)).unwrap();
120                    writeln!(output, "   New Value: {}", Self::truncate_value(new)).unwrap();
121                },
122                (Some(old), None) => {
123                    writeln!(output, "   Removed Value: {}", Self::truncate_value(old)).unwrap();
124                },
125                (None, Some(new)) => {
126                    writeln!(output, "   Added Value: {}", Self::truncate_value(new)).unwrap();
127                },
128                (None, None) => {},
129            }
130            writeln!(output).unwrap();
131        }
132        
133        output
134    }
135    
136    /// Format changeset as JSON Patch (RFC 6902)
137    pub fn format_json_patch(changeset: &ChangeSet) -> Result<String, BuildError> {
138        let mut patches = Vec::new();
139        
140        for change in &changeset.changes {
141            let path = Self::path_to_json_pointer(&change.path);
142            
143            let patch = match change.change_type {
144                ChangeType::ElementAdded | ChangeType::AttributeAdded => {
145                    json!({
146                        "op": "add",
147                        "path": path,
148                        "value": change.new_value.clone().unwrap_or_default()
149                    })
150                },
151                ChangeType::ElementRemoved | ChangeType::AttributeRemoved => {
152                    json!({
153                        "op": "remove", 
154                        "path": path
155                    })
156                },
157                ChangeType::ElementModified | ChangeType::AttributeModified | 
158                ChangeType::TextModified => {
159                    json!({
160                        "op": "replace",
161                        "path": path,
162                        "value": change.new_value.clone().unwrap_or_default()
163                    })
164                },
165                ChangeType::ElementMoved => {
166                    // JSON Patch move operation would require more complex path resolution
167                    json!({
168                        "op": "move",
169                        "from": path,
170                        "path": path // Simplified - would need actual destination
171                    })
172                },
173                ChangeType::ElementRenamed => {
174                    // Handle as remove + add for JSON Patch
175                    json!({
176                        "op": "replace",
177                        "path": path,
178                        "value": change.new_value.clone().unwrap_or_default()
179                    })
180                },
181            };
182            
183            patches.push(patch);
184        }
185        
186        serde_json::to_string_pretty(&patches)
187            .map_err(|e| BuildError::Serialization(e.to_string()))
188    }
189    
190    /// Format changeset as JSON for programmatic use
191    pub fn format_json(changeset: &ChangeSet) -> Result<String, BuildError> {
192        let json = json!({
193            "timestamp": changeset.timestamp.to_rfc3339(),
194            "summary": {
195                "total_changes": changeset.summary.total_changes,
196                "additions": changeset.summary.additions,
197                "deletions": changeset.summary.deletions,
198                "modifications": changeset.summary.modifications,
199                "moves": changeset.summary.moves,
200                "critical_changes": changeset.summary.critical_changes,
201                "impact_level": changeset.impact_level().to_string(),
202                "has_changes": changeset.has_changes()
203            },
204            "changes": changeset.changes.iter().map(|change| json!({
205                "path": change.path.to_string(),
206                "type": change.change_type.to_string(),
207                "critical": change.is_critical,
208                "description": change.description,
209                "old_value": change.old_value,
210                "new_value": change.new_value
211            })).collect::<Vec<_>>(),
212            "metadata": changeset.metadata
213        });
214        
215        serde_json::to_string_pretty(&json)
216            .map_err(|e| BuildError::Serialization(e.to_string()))
217    }
218    
219    /// Format changeset as HTML report
220    pub fn format_html(changeset: &ChangeSet) -> String {
221        let mut html = String::new();
222        
223        // HTML header
224        html.push_str(r#"<!DOCTYPE html>
225<html>
226<head>
227    <title>DDEX Semantic Diff Report</title>
228    <style>
229        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; }
230        .header { border-bottom: 2px solid #ddd; padding-bottom: 20px; margin-bottom: 30px; }
231        .summary { background: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 30px; }
232        .change-group { margin-bottom: 30px; }
233        .change-type { font-weight: bold; font-size: 1.2em; margin-bottom: 15px; }
234        .change-item { background: white; border: 1px solid #ddd; border-radius: 4px; padding: 15px; margin-bottom: 10px; }
235        .critical { border-left: 4px solid #dc3545; }
236        .added { border-left: 4px solid #28a745; }
237        .removed { border-left: 4px solid #dc3545; }
238        .modified { border-left: 4px solid #ffc107; }
239        .path { font-family: monospace; background: #f1f1f1; padding: 2px 6px; border-radius: 3px; }
240        .value { font-family: monospace; background: #f8f8f8; padding: 8px; border-radius: 3px; margin: 5px 0; }
241        .old-value { background-color: #ffebee; }
242        .new-value { background-color: #e8f5e8; }
243        .impact-high { color: #dc3545; }
244        .impact-medium { color: #ffc107; }
245        .impact-low { color: #28a745; }
246        .impact-none { color: #6c757d; }
247    </style>
248</head>
249<body>
250"#);
251        
252        // Header
253        html.push_str(&format!(r#"
254    <div class="header">
255        <h1>DDEX Semantic Diff Report</h1>
256        <p>Generated: {}</p>
257        <p>Impact Level: <span class="impact-{}">{}</span></p>
258        <p>Summary: {}</p>
259    </div>
260"#, 
261            changeset.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
262            changeset.impact_level().to_string().to_lowercase(),
263            changeset.impact_level(),
264            changeset.summary.summary_string()
265        ));
266        
267        if !changeset.has_changes() {
268            html.push_str("<div class='summary'><h2>✅ No Changes</h2><p>No semantic changes detected between the documents.</p></div>");
269        } else {
270            // Summary statistics
271            html.push_str(&format!(r#"
272    <div class="summary">
273        <h2>Summary Statistics</h2>
274        <ul>
275            <li>Total Changes: {}</li>
276            <li>Additions: {}</li>
277            <li>Deletions: {}</li>
278            <li>Modifications: {}</li>
279            <li>Moves: {}</li>
280            <li>Critical Changes: {}</li>
281        </ul>
282    </div>
283"#,
284                changeset.summary.total_changes,
285                changeset.summary.additions,
286                changeset.summary.deletions,
287                changeset.summary.modifications,
288                changeset.summary.moves,
289                changeset.summary.critical_changes
290            ));
291            
292            // Critical changes first
293            let critical_changes = changeset.critical_changes();
294            if !critical_changes.is_empty() {
295                html.push_str("<div class='change-group'>");
296                html.push_str("<div class='change-type'>🚨 Critical Changes</div>");
297                
298                for change in critical_changes {
299                    html.push_str(&Self::format_change_html(change, "critical"));
300                }
301                
302                html.push_str("</div>");
303            }
304            
305            // Group other changes by type
306            let mut changes_by_type: IndexMap<ChangeType, Vec<&SemanticChange>> = IndexMap::new();
307            for change in &changeset.changes {
308                if !change.is_critical {
309                    changes_by_type.entry(change.change_type).or_default().push(change);
310                }
311            }
312            
313            for (change_type, changes) in changes_by_type {
314                if !changes.is_empty() {
315                    html.push_str("<div class='change-group'>");
316                    html.push_str(&format!("<div class='change-type'>{} {} ({})</div>", 
317                        Self::change_type_icon(change_type), change_type, changes.len()));
318                    
319                    let css_class = match change_type {
320                        ChangeType::ElementAdded | ChangeType::AttributeAdded => "added",
321                        ChangeType::ElementRemoved | ChangeType::AttributeRemoved => "removed", 
322                        _ => "modified",
323                    };
324                    
325                    for change in changes {
326                        html.push_str(&Self::format_change_html(change, css_class));
327                    }
328                    
329                    html.push_str("</div>");
330                }
331            }
332        }
333        
334        // HTML footer
335        html.push_str("</body></html>");
336        
337        html
338    }
339    
340    /// Generate DDEX UpdateReleaseMessage from changeset
341    pub fn generate_update_message(changeset: &ChangeSet, message_id: Option<&str>) -> Result<String, BuildError> {
342        let mut xml = String::new();
343        
344        // XML declaration and root element
345        xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
346        xml.push('\n');
347        xml.push_str(r#"<UpdateReleaseMessage xmlns="http://ddex.net/xml/ern/43" MessageSchemaVersionId="ern/43">"#);
348        xml.push('\n');
349        
350        // Message header
351        xml.push_str("  <MessageHeader>\n");
352        xml.push_str(&format!("    <MessageId>{}</MessageId>\n", 
353            message_id.unwrap_or(&uuid::Uuid::new_v4().to_string())));
354        xml.push_str("    <MessageSender>\n");
355        xml.push_str("      <PartyName>DDEX Suite Diff Engine</PartyName>\n");
356        xml.push_str("    </MessageSender>\n");
357        xml.push_str("    <MessageRecipient>\n");
358        xml.push_str("      <PartyName>Recipient</PartyName>\n");
359        xml.push_str("    </MessageRecipient>\n");
360        xml.push_str(&format!("    <MessageCreatedDateTime>{}</MessageCreatedDateTime>\n", 
361            changeset.timestamp.to_rfc3339()));
362        xml.push_str("  </MessageHeader>\n");
363        
364        // Update list (simplified - real implementation would group by entity)
365        xml.push_str("  <UpdateList>\n");
366        
367        for change in &changeset.changes {
368            xml.push_str("    <Update>\n");
369            xml.push_str(&format!("      <UpdateType>{}</UpdateType>\n", 
370                Self::change_type_to_update_type(change.change_type)));
371            xml.push_str(&format!("      <UpdatePath>{}</UpdatePath>\n", 
372                html_escape::encode_text(&change.path.to_string())));
373            
374            if let Some(old_val) = &change.old_value {
375                xml.push_str(&format!("      <OldValue>{}</OldValue>\n", 
376                    html_escape::encode_text(old_val)));
377            }
378            if let Some(new_val) = &change.new_value {
379                xml.push_str(&format!("      <NewValue>{}</NewValue>\n", 
380                    html_escape::encode_text(new_val)));
381            }
382            
383            xml.push_str(&format!("      <IsCritical>{}</IsCritical>\n", change.is_critical));
384            xml.push_str("    </Update>\n");
385        }
386        
387        xml.push_str("  </UpdateList>\n");
388        xml.push_str("</UpdateReleaseMessage>\n");
389        
390        Ok(xml)
391    }
392    
393    // Helper methods
394    
395    fn change_type_icon(change_type: ChangeType) -> &'static str {
396        match change_type {
397            ChangeType::ElementAdded | ChangeType::AttributeAdded => "➕",
398            ChangeType::ElementRemoved | ChangeType::AttributeRemoved => "➖",
399            ChangeType::ElementModified | ChangeType::AttributeModified => "✏️",
400            ChangeType::TextModified => "📝",
401            ChangeType::ElementRenamed => "🔄",
402            ChangeType::ElementMoved => "🔄",
403        }
404    }
405    
406    fn truncate_value(value: &str) -> String {
407        if value.len() > 100 {
408            format!("{}...", &value[..97])
409        } else {
410            value.to_string()
411        }
412    }
413    
414    fn path_to_json_pointer(path: &super::types::DiffPath) -> String {
415        let mut pointer = String::new();
416        for segment in &path.segments {
417            pointer.push('/');
418            match segment {
419                super::types::PathSegment::Element(name) => pointer.push_str(name),
420                super::types::PathSegment::Attribute(name) => {
421                    pointer.push('@');
422                    pointer.push_str(name);
423                },
424                super::types::PathSegment::Text => pointer.push_str("text()"),
425                super::types::PathSegment::Index(idx) => pointer.push_str(&idx.to_string()),
426            }
427        }
428        if pointer.is_empty() {
429            "/".to_string()
430        } else {
431            pointer
432        }
433    }
434    
435    fn format_change_html(change: &SemanticChange, css_class: &str) -> String {
436        let mut html = format!("<div class='change-item {}'>\n", css_class);
437        html.push_str(&format!("  <div><strong>{}</strong></div>\n", 
438            html_escape::encode_text(&change.description)));
439        html.push_str(&format!("  <div>Path: <span class='path'>{}</span></div>\n", 
440            html_escape::encode_text(&change.path.to_string())));
441        
442        match (&change.old_value, &change.new_value) {
443            (Some(old), Some(new)) => {
444                html.push_str(&format!("  <div class='value old-value'>Old: {}</div>\n", 
445                    html_escape::encode_text(&Self::truncate_value(old))));
446                html.push_str(&format!("  <div class='value new-value'>New: {}</div>\n", 
447                    html_escape::encode_text(&Self::truncate_value(new))));
448            },
449            (Some(old), None) => {
450                html.push_str(&format!("  <div class='value old-value'>Removed: {}</div>\n", 
451                    html_escape::encode_text(&Self::truncate_value(old))));
452            },
453            (None, Some(new)) => {
454                html.push_str(&format!("  <div class='value new-value'>Added: {}</div>\n", 
455                    html_escape::encode_text(&Self::truncate_value(new))));
456            },
457            (None, None) => {},
458        }
459        
460        html.push_str("</div>\n");
461        html
462    }
463    
464    fn change_type_to_update_type(change_type: ChangeType) -> &'static str {
465        match change_type {
466            ChangeType::ElementAdded | ChangeType::AttributeAdded => "Add",
467            ChangeType::ElementRemoved | ChangeType::AttributeRemoved => "Remove",
468            ChangeType::ElementModified | ChangeType::AttributeModified | 
469            ChangeType::TextModified => "Modify",
470            ChangeType::ElementRenamed => "Rename",
471            ChangeType::ElementMoved => "Move",
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::diff::types::{SemanticChange, DiffPath, ChangeType};
480    
481    fn create_test_changeset() -> ChangeSet {
482        let mut changeset = ChangeSet::new();
483        
484        changeset.add_change(SemanticChange {
485            path: DiffPath::root().with_element("Release").with_attribute("UPC"),
486            change_type: ChangeType::AttributeModified,
487            old_value: Some("123456789".to_string()),
488            new_value: Some("987654321".to_string()),
489            is_critical: true,
490            description: "UPC changed".to_string(),
491        });
492        
493        changeset.add_change(SemanticChange {
494            path: DiffPath::root().with_element("Release").with_element("Title"),
495            change_type: ChangeType::TextModified,
496            old_value: Some("Old Title".to_string()),
497            new_value: Some("New Title".to_string()),
498            is_critical: false,
499            description: "Title changed".to_string(),
500        });
501        
502        changeset
503    }
504    
505    #[test]
506    fn test_format_summary() {
507        let changeset = create_test_changeset();
508        let summary = DiffFormatter::format_summary(&changeset);
509        
510        assert!(summary.contains("DDEX Semantic Diff Summary"));
511        assert!(summary.contains("Critical Changes"));
512        assert!(summary.contains("UPC changed"));
513    }
514    
515    #[test]
516    fn test_format_json() {
517        let changeset = create_test_changeset();
518        let json_result = DiffFormatter::format_json(&changeset);
519        
520        assert!(json_result.is_ok());
521        let json_str = json_result.unwrap();
522        assert!(json_str.contains("total_changes"));
523        assert!(json_str.contains("critical_changes"));
524    }
525    
526    #[test]
527    fn test_format_json_patch() {
528        let changeset = create_test_changeset();
529        let patch_result = DiffFormatter::format_json_patch(&changeset);
530        
531        assert!(patch_result.is_ok());
532        let patch_str = patch_result.unwrap();
533        assert!(patch_str.contains("\"op\":"));
534        assert!(patch_str.contains("\"path\":"));
535    }
536    
537    #[test]
538    fn test_format_html() {
539        let changeset = create_test_changeset();
540        let html = DiffFormatter::format_html(&changeset);
541        
542        assert!(html.contains("<!DOCTYPE html>"));
543        assert!(html.contains("DDEX Semantic Diff Report"));
544        assert!(html.contains("Critical Changes"));
545    }
546}