1use super::types::{ChangeSet, ChangeType, SemanticChange};
4use crate::error::BuildError;
5use indexmap::IndexMap;
6use serde_json::json;
7use std::fmt::Write;
8
9pub struct DiffFormatter;
11
12impl DiffFormatter {
13 pub fn format_summary(changeset: &ChangeSet) -> String {
15 let mut output = String::new();
16
17 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 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 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 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 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 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 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 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!({
209 "op": "move",
210 "from": path,
211 "path": path })
213 }
214 ChangeType::ElementRenamed => {
215 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 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 pub fn format_html(changeset: &ChangeSet) -> String {
260 let mut html = String::new();
261
262 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 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 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 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 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.push_str("</body></html>");
384
385 html
386 }
387
388 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.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 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 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 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}