ddex_builder/diff/
formatter.rs

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