1use super::types::{ChangeSet, SemanticChange, ChangeType};
4use crate::error::BuildError;
5use serde_json::json;
6use indexmap::IndexMap;
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!(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 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 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 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 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 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 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 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!({
168 "op": "move",
169 "from": path,
170 "path": path })
172 },
173 ChangeType::ElementRenamed => {
174 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 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 pub fn format_html(changeset: &ChangeSet) -> String {
221 let mut html = String::new();
222
223 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 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 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 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 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.push_str("</body></html>");
336
337 html
338 }
339
340 pub fn generate_update_message(changeset: &ChangeSet, message_id: Option<&str>) -> Result<String, BuildError> {
342 let mut xml = String::new();
343
344 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 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 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 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}