xldiff.rs (25017B)
1 use std::io::IsTerminal; 2 use std::path::PathBuf; 3 use std::process; 4 5 use anyhow::{Result, bail}; 6 use clap::Parser; 7 use serde_json::{Map, Value, json}; 8 9 use xlcat::diff::{DiffOptions, DiffResult, SheetSource}; 10 use xlcat::filter; 11 use xlcat::formatter; 12 use xlcat::metadata; 13 use xlcat::reader; 14 15 #[derive(Parser)] 16 #[command( 17 name = "xldiff", 18 about = "Compare two Excel spreadsheets and show differences", 19 version 20 )] 21 struct Args { 22 /// First file (optionally file.xlsx:SheetName) 23 file_a: String, 24 25 /// Second file (optionally file.xlsx:SheetName) 26 file_b: String, 27 28 /// Key column(s) for matching rows (comma-separated names or letters) 29 #[arg(long)] 30 key: Option<String>, 31 32 /// Columns to compare (comma-separated names or letters) 33 #[arg(long)] 34 cols: Option<String>, 35 36 /// Rows to skip before header: single number or "skipA,skipB" 37 #[arg(long, default_value = "0")] 38 skip: String, 39 40 /// Numeric tolerance for float comparisons 41 #[arg(long)] 42 tolerance: Option<f64>, 43 44 /// Output format: text, markdown, json, csv 45 #[arg(long, default_value = "text")] 46 format: String, 47 48 /// Disable colored output 49 #[arg(long)] 50 no_color: bool, 51 } 52 53 // --------------------------------------------------------------------------- 54 // Helpers 55 // --------------------------------------------------------------------------- 56 57 /// Split a CLI argument like "file.xlsx:Sheet1" into (path, optional sheet). 58 /// 59 /// Handles Windows drive letters (e.g. C:\file.xlsx) by ignoring a colon 60 /// at position 1 when followed by `\` or `/`. A trailing colon with nothing 61 /// after it is treated as no sheet specification. 62 fn parse_file_arg(arg: &str) -> (PathBuf, Option<String>) { 63 // Find the *last* colon — that's the sheet separator. 64 if let Some(pos) = arg.rfind(':') { 65 // Skip Windows drive letters: colon at position 1 followed by \ or / 66 if pos == 1 { 67 let after = arg.as_bytes().get(2); 68 if after == Some(&b'\\') || after == Some(&b'/') { 69 return (PathBuf::from(arg), None); 70 } 71 } 72 let sheet_part = &arg[pos + 1..]; 73 if sheet_part.is_empty() { 74 // Trailing colon — ignore it 75 return (PathBuf::from(&arg[..pos]), None); 76 } 77 (PathBuf::from(&arg[..pos]), Some(sheet_part.to_string())) 78 } else { 79 (PathBuf::from(arg), None) 80 } 81 } 82 83 /// Parse the --skip flag: either "3" (same skip for both) or "3,5". 84 fn parse_skip(s: &str) -> Result<(usize, usize)> { 85 if let Some((a, b)) = s.split_once(',') { 86 let skip_a: usize = a 87 .trim() 88 .parse() 89 .map_err(|_| anyhow::anyhow!("invalid skip value: '{}'", a.trim()))?; 90 let skip_b: usize = b 91 .trim() 92 .parse() 93 .map_err(|_| anyhow::anyhow!("invalid skip value: '{}'", b.trim()))?; 94 Ok((skip_a, skip_b)) 95 } else { 96 let skip: usize = s 97 .trim() 98 .parse() 99 .map_err(|_| anyhow::anyhow!("invalid skip value: '{}'", s.trim()))?; 100 Ok((skip, skip)) 101 } 102 } 103 104 /// Resolve a sheet name: exact match, then 0-based index, then error. 105 fn resolve_sheet( 106 info: &metadata::FileInfo, 107 sheet_arg: Option<&str>, 108 ) -> Result<String> { 109 match sheet_arg { 110 None => info 111 .sheets 112 .first() 113 .map(|s| s.name.clone()) 114 .ok_or_else(|| anyhow::anyhow!("workbook has no sheets")), 115 Some(s) => { 116 // Exact name match 117 if let Some(sheet) = info.sheets.iter().find(|si| si.name == s) { 118 return Ok(sheet.name.clone()); 119 } 120 // 0-based index 121 if let Ok(idx) = s.parse::<usize>() 122 && let Some(sheet) = info.sheets.get(idx) 123 { 124 return Ok(sheet.name.clone()); 125 } 126 let names: Vec<_> = info.sheets.iter().map(|si| si.name.as_str()).collect(); 127 bail!( 128 "sheet '{}' not found. Available sheets: {}", 129 s, 130 names.join(", ") 131 ) 132 } 133 } 134 } 135 136 // --------------------------------------------------------------------------- 137 // Output formatters 138 // --------------------------------------------------------------------------- 139 140 /// Format a row's values inline: `Name: "Alice" Score: "90"` 141 fn format_row_inline(headers: &[String], values: &[String]) -> String { 142 headers 143 .iter() 144 .zip(values.iter()) 145 .map(|(h, v)| format!("{}: \"{}\"", h, v)) 146 .collect::<Vec<_>>() 147 .join(" ") 148 } 149 150 /// Format diff result as colored (or plain) text output. 151 fn format_text(result: &DiffResult, color: bool) -> String { 152 if !result.has_differences() { 153 return "No differences found.\n".to_string(); 154 } 155 156 let (red, green, yellow, reset) = if color { 157 ("\x1b[31m", "\x1b[32m", "\x1b[33m", "\x1b[0m") 158 } else { 159 ("", "", "", "") 160 }; 161 162 let mut out = String::new(); 163 164 // Header 165 out.push_str(&format!( 166 "--- {} ({})\n+++ {} ({})\n\n", 167 result.source_a.sheet_name, 168 result.source_a.file_name, 169 result.source_b.sheet_name, 170 result.source_b.file_name, 171 )); 172 173 // Summary 174 out.push_str(&format!( 175 "Added: {} | Removed: {} | Modified: {}\n\n", 176 result.added.len(), 177 result.removed.len(), 178 result.modified.len(), 179 )); 180 181 // Removed rows 182 for row in &result.removed { 183 out.push_str(&format!( 184 "{}- {}{}", 185 red, 186 format_row_inline(&result.headers, &row.values), 187 reset, 188 )); 189 out.push('\n'); 190 } 191 192 // Added rows 193 for row in &result.added { 194 out.push_str(&format!( 195 "{}+ {}{}", 196 green, 197 format_row_inline(&result.headers, &row.values), 198 reset, 199 )); 200 out.push('\n'); 201 } 202 203 // Modified rows 204 for m in &result.modified { 205 // Build key display: Key: "value" Key2: "value2" 206 let key_display: Vec<String> = result 207 .key_columns 208 .iter() 209 .zip(m.key.iter()) 210 .map(|(col, val)| format!("{}: \"{}\"", col, val)) 211 .collect(); 212 out.push_str(&format!( 213 "{}~ {}{}", 214 yellow, 215 key_display.join(" "), 216 reset, 217 )); 218 out.push('\n'); 219 for change in &m.changes { 220 out.push_str(&format!( 221 " {}: \"{}\" \u{2192} \"{}\"\n", 222 change.column, change.old_value, change.new_value, 223 )); 224 } 225 } 226 227 out 228 } 229 230 /// Format diff result as markdown. 231 fn format_markdown(result: &DiffResult) -> String { 232 if !result.has_differences() { 233 return "No differences found.\n".to_string(); 234 } 235 236 let mut out = String::new(); 237 238 // Added 239 if !result.added.is_empty() { 240 out.push_str(&format!("## Added ({})\n\n", result.added.len())); 241 let rows: Vec<Vec<String>> = result 242 .added 243 .iter() 244 .map(|r| r.values.clone()) 245 .collect(); 246 out.push_str(&formatter::render_table(&result.headers, &rows)); 247 out.push('\n'); 248 } 249 250 // Removed 251 if !result.removed.is_empty() { 252 out.push_str(&format!("## Removed ({})\n\n", result.removed.len())); 253 let rows: Vec<Vec<String>> = result 254 .removed 255 .iter() 256 .map(|r| r.values.clone()) 257 .collect(); 258 out.push_str(&formatter::render_table(&result.headers, &rows)); 259 out.push('\n'); 260 } 261 262 // Modified 263 if !result.modified.is_empty() { 264 out.push_str(&format!("## Modified ({})\n\n", result.modified.len())); 265 266 let key_label = if result.key_columns.len() == 1 { 267 format!("Key ({})", result.key_columns[0]) 268 } else { 269 format!("Key ({})", result.key_columns.join(", ")) 270 }; 271 let headers = vec![ 272 key_label, 273 "Column".to_string(), 274 "Old".to_string(), 275 "New".to_string(), 276 ]; 277 let mut rows: Vec<Vec<String>> = Vec::new(); 278 279 for m in &result.modified { 280 let key_display = m.key.join(", "); 281 282 for (i, change) in m.changes.iter().enumerate() { 283 let key_cell = if i == 0 { 284 key_display.clone() 285 } else { 286 String::new() 287 }; 288 rows.push(vec![ 289 key_cell, 290 change.column.clone(), 291 change.old_value.clone(), 292 change.new_value.clone(), 293 ]); 294 } 295 } 296 297 out.push_str(&formatter::render_table(&headers, &rows)); 298 out.push('\n'); 299 } 300 301 out 302 } 303 304 /// Format diff result as JSON. 305 fn format_json(result: &DiffResult) -> String { 306 let added: Vec<Value> = result 307 .added 308 .iter() 309 .map(|row| { 310 let mut map = Map::new(); 311 for (h, v) in result.headers.iter().zip(row.values.iter()) { 312 map.insert(h.clone(), Value::String(v.clone())); 313 } 314 Value::Object(map) 315 }) 316 .collect(); 317 318 let removed: Vec<Value> = result 319 .removed 320 .iter() 321 .map(|row| { 322 let mut map = Map::new(); 323 for (h, v) in result.headers.iter().zip(row.values.iter()) { 324 map.insert(h.clone(), Value::String(v.clone())); 325 } 326 Value::Object(map) 327 }) 328 .collect(); 329 330 let modified: Vec<Value> = result 331 .modified 332 .iter() 333 .map(|m| { 334 let mut key_map = Map::new(); 335 for (col, val) in result.key_columns.iter().zip(m.key.iter()) { 336 key_map.insert(col.clone(), Value::String(val.clone())); 337 } 338 let changes: Vec<Value> = m 339 .changes 340 .iter() 341 .map(|c| { 342 json!({ 343 "column": c.column, 344 "old": c.old_value, 345 "new": c.new_value, 346 }) 347 }) 348 .collect(); 349 json!({ 350 "key": Value::Object(key_map), 351 "changes": changes, 352 }) 353 }) 354 .collect(); 355 356 let output = json!({ 357 "added": added, 358 "removed": removed, 359 "modified": modified, 360 }); 361 362 serde_json::to_string_pretty(&output).unwrap() + "\n" 363 } 364 365 /// Quote a value per RFC 4180: if it contains comma, quote, or newline, wrap 366 /// in double quotes and escape any internal quotes by doubling them. 367 fn csv_quote(value: &str) -> String { 368 if value.contains(',') || value.contains('"') || value.contains('\n') { 369 format!("\"{}\"", value.replace('"', "\"\"")) 370 } else { 371 value.to_string() 372 } 373 } 374 375 /// Build a CSV row from a slice of values. 376 fn csv_row(values: &[String]) -> String { 377 values.iter().map(|v| csv_quote(v)).collect::<Vec<_>>().join(",") 378 } 379 380 /// Format diff result as CSV. 381 /// 382 /// Header: _status, col1, col2, ..., _old_col1, _old_col2, ... 383 /// Added rows: "added" + values + empty _old_ columns 384 /// Removed rows: "removed" + values + empty _old_ columns 385 /// Modified rows: "modified" + new values (key cols + changed new values) + old values in _old_ columns 386 fn format_csv(result: &DiffResult) -> String { 387 let mut out = String::new(); 388 389 // Build header 390 let mut header_parts: Vec<String> = vec!["_status".to_string()]; 391 for h in &result.headers { 392 header_parts.push(h.clone()); 393 } 394 for h in &result.headers { 395 header_parts.push(format!("_old_{}", h)); 396 } 397 out.push_str(&csv_row(&header_parts)); 398 out.push('\n'); 399 400 let empty_cols: Vec<String> = result.headers.iter().map(|_| String::new()).collect(); 401 402 // Removed rows 403 for row in &result.removed { 404 let mut parts: Vec<String> = vec!["removed".to_string()]; 405 parts.extend(row.values.iter().cloned()); 406 // Pad if row has fewer values than headers 407 while parts.len() < 1 + result.headers.len() { 408 parts.push(String::new()); 409 } 410 parts.extend(empty_cols.iter().cloned()); 411 out.push_str(&csv_row(&parts)); 412 out.push('\n'); 413 } 414 415 // Added rows 416 for row in &result.added { 417 let mut parts: Vec<String> = vec!["added".to_string()]; 418 parts.extend(row.values.iter().cloned()); 419 while parts.len() < 1 + result.headers.len() { 420 parts.push(String::new()); 421 } 422 parts.extend(empty_cols.iter().cloned()); 423 out.push_str(&csv_row(&parts)); 424 out.push('\n'); 425 } 426 427 // Modified rows 428 for m in &result.modified { 429 let mut main_cols: Vec<String> = Vec::new(); 430 let mut old_cols: Vec<String> = Vec::new(); 431 432 for h in &result.headers { 433 // Check if this is a key column 434 if let Some(key_idx) = result.key_columns.iter().position(|k| k == h) { 435 main_cols.push(m.key.get(key_idx).cloned().unwrap_or_default()); 436 old_cols.push(String::new()); 437 } else if let Some(change) = m.changes.iter().find(|c| c.column == *h) { 438 main_cols.push(change.new_value.clone()); 439 old_cols.push(change.old_value.clone()); 440 } else { 441 // Unchanged non-key column — leave empty in both 442 main_cols.push(String::new()); 443 old_cols.push(String::new()); 444 } 445 } 446 447 let mut parts: Vec<String> = vec!["modified".to_string()]; 448 parts.extend(main_cols); 449 parts.extend(old_cols); 450 out.push_str(&csv_row(&parts)); 451 out.push('\n'); 452 } 453 454 out 455 } 456 457 // --------------------------------------------------------------------------- 458 // run / main 459 // --------------------------------------------------------------------------- 460 461 fn run(args: Args) -> Result<()> { 462 // Parse file arguments 463 let (path_a, sheet_arg_a) = parse_file_arg(&args.file_a); 464 let (path_b, sheet_arg_b) = parse_file_arg(&args.file_b); 465 466 // Validate files exist 467 if !path_a.exists() { 468 bail!("file not found: {}", path_a.display()); 469 } 470 if !path_b.exists() { 471 bail!("file not found: {}", path_b.display()); 472 } 473 474 // Validate format 475 let format = args.format.to_lowercase(); 476 if !["text", "markdown", "json", "csv"].contains(&format.as_str()) { 477 bail!( 478 "unknown format '{}'. Use: text, markdown, json, csv", 479 args.format 480 ); 481 } 482 483 // Parse skip 484 let (skip_a, skip_b) = parse_skip(&args.skip)?; 485 486 // Read file info and resolve sheets 487 let info_a = metadata::read_file_info(&path_a)?; 488 let info_b = metadata::read_file_info(&path_b)?; 489 490 let sheet_a = resolve_sheet(&info_a, sheet_arg_a.as_deref())?; 491 let sheet_b = resolve_sheet(&info_b, sheet_arg_b.as_deref())?; 492 493 // Read DataFrames 494 let df_a = if skip_a > 0 { 495 reader::read_sheet_with_skip(&path_a, &sheet_a, skip_a)? 496 } else { 497 reader::read_sheet(&path_a, &sheet_a)? 498 }; 499 let df_b = if skip_b > 0 { 500 reader::read_sheet_with_skip(&path_b, &sheet_b, skip_b)? 501 } else { 502 reader::read_sheet(&path_b, &sheet_b)? 503 }; 504 505 // Resolve key columns 506 let key_columns: Vec<String> = if let Some(ref key_str) = args.key { 507 let specs: Vec<String> = key_str.split(',').map(|s| s.trim().to_string()).collect(); 508 509 let df_a_cols: Vec<String> = df_a 510 .get_column_names() 511 .iter() 512 .map(|s| s.to_string()) 513 .collect(); 514 let df_b_cols: Vec<String> = df_b 515 .get_column_names() 516 .iter() 517 .map(|s| s.to_string()) 518 .collect(); 519 520 let resolved_a = filter::resolve_columns(&specs, &df_a_cols) 521 .map_err(|e| anyhow::anyhow!("first file: {}", e))?; 522 let resolved_b = filter::resolve_columns(&specs, &df_b_cols) 523 .map_err(|e| anyhow::anyhow!("second file: {}", e))?; 524 525 // Validate keys match between both files 526 if resolved_a != resolved_b { 527 bail!( 528 "key columns resolve to different names: {:?} vs {:?}", 529 resolved_a, 530 resolved_b 531 ); 532 } 533 resolved_a 534 } else { 535 vec![] 536 }; 537 538 // Column filtering with --cols 539 let (df_a, df_b) = if let Some(ref cols_str) = args.cols { 540 let specs: Vec<String> = cols_str.split(',').map(|s| s.trim().to_string()).collect(); 541 542 let df_a_cols: Vec<String> = df_a 543 .get_column_names() 544 .iter() 545 .map(|s| s.to_string()) 546 .collect(); 547 let df_b_cols: Vec<String> = df_b 548 .get_column_names() 549 .iter() 550 .map(|s| s.to_string()) 551 .collect(); 552 553 let mut selected_a = filter::resolve_columns(&specs, &df_a_cols) 554 .map_err(|e| anyhow::anyhow!(e))?; 555 let mut selected_b = filter::resolve_columns(&specs, &df_b_cols) 556 .map_err(|e| anyhow::anyhow!(e))?; 557 558 // Ensure key columns are included 559 for key in &key_columns { 560 if !selected_a.contains(key) { 561 selected_a.insert(0, key.clone()); 562 } 563 if !selected_b.contains(key) { 564 selected_b.insert(0, key.clone()); 565 } 566 } 567 568 let df_a = df_a.select(selected_a.iter().map(|s| s.as_str()))?; 569 let df_b = df_b.select(selected_b.iter().map(|s| s.as_str()))?; 570 (df_a, df_b) 571 } else { 572 (df_a, df_b) 573 }; 574 575 // Build sources and options 576 let file_name_a = path_a 577 .file_name() 578 .map(|s| s.to_string_lossy().to_string()) 579 .unwrap_or_else(|| args.file_a.clone()); 580 let file_name_b = path_b 581 .file_name() 582 .map(|s| s.to_string_lossy().to_string()) 583 .unwrap_or_else(|| args.file_b.clone()); 584 585 let source_a = SheetSource { 586 file_name: file_name_a, 587 sheet_name: sheet_a, 588 }; 589 let source_b = SheetSource { 590 file_name: file_name_b, 591 sheet_name: sheet_b, 592 }; 593 594 let opts = DiffOptions { 595 key_columns, 596 tolerance: args.tolerance, 597 }; 598 599 // Run diff 600 let result = xlcat::diff::diff_sheets(&df_a, &df_b, &opts, source_a, source_b)?; 601 602 // TTY detection for color 603 let use_color = !args.no_color && std::io::stdout().is_terminal(); 604 605 // Format output 606 let output = match format.as_str() { 607 "text" => format_text(&result, use_color), 608 "markdown" => format_markdown(&result), 609 "json" => format_json(&result), 610 "csv" => format_csv(&result), 611 _ => unreachable!(), 612 }; 613 614 print!("{}", output); 615 616 // Exit 1 if differences found (diff convention) 617 if result.has_differences() { 618 process::exit(1); 619 } 620 621 Ok(()) 622 } 623 624 fn main() { 625 let args = Args::parse(); 626 if let Err(err) = run(args) { 627 eprintln!("xldiff: {err}"); 628 process::exit(2); 629 } 630 } 631 632 #[cfg(test)] 633 mod tests { 634 use super::*; 635 use xlcat::diff::{CellChange, DiffRow, ModifiedRow}; 636 637 // -- parse_file_arg -- 638 639 #[test] 640 fn test_parse_file_arg_no_sheet() { 641 let (path, sheet) = parse_file_arg("data.xlsx"); 642 assert_eq!(path, PathBuf::from("data.xlsx")); 643 assert_eq!(sheet, None); 644 } 645 646 #[test] 647 fn test_parse_file_arg_with_sheet() { 648 let (path, sheet) = parse_file_arg("data.xlsx:Revenue"); 649 assert_eq!(path, PathBuf::from("data.xlsx")); 650 assert_eq!(sheet, Some("Revenue".to_string())); 651 } 652 653 #[test] 654 fn test_parse_file_arg_trailing_colon() { 655 let (path, sheet) = parse_file_arg("data.xlsx:"); 656 assert_eq!(path, PathBuf::from("data.xlsx")); 657 assert_eq!(sheet, None); 658 } 659 660 #[test] 661 fn test_parse_file_arg_windows_drive() { 662 let (path, sheet) = parse_file_arg("C:\\Users\\file.xlsx"); 663 assert_eq!(path, PathBuf::from("C:\\Users\\file.xlsx")); 664 assert_eq!(sheet, None); 665 } 666 667 #[test] 668 fn test_parse_file_arg_windows_drive_with_sheet() { 669 let (path, sheet) = parse_file_arg("C:\\Users\\file.xlsx:Sheet2"); 670 assert_eq!(path, PathBuf::from("C:\\Users\\file.xlsx")); 671 assert_eq!(sheet, Some("Sheet2".to_string())); 672 } 673 674 // -- parse_skip -- 675 676 #[test] 677 fn test_parse_skip_single() { 678 assert_eq!(parse_skip("3").unwrap(), (3, 3)); 679 } 680 681 #[test] 682 fn test_parse_skip_pair() { 683 assert_eq!(parse_skip("3,5").unwrap(), (3, 5)); 684 } 685 686 #[test] 687 fn test_parse_skip_zero() { 688 assert_eq!(parse_skip("0").unwrap(), (0, 0)); 689 } 690 691 #[test] 692 fn test_parse_skip_invalid() { 693 assert!(parse_skip("abc").is_err()); 694 } 695 696 // -- csv_quote -- 697 698 #[test] 699 fn test_csv_quote_plain() { 700 assert_eq!(csv_quote("hello"), "hello"); 701 } 702 703 #[test] 704 fn test_csv_quote_comma() { 705 assert_eq!(csv_quote("a,b"), "\"a,b\""); 706 } 707 708 #[test] 709 fn test_csv_quote_quotes() { 710 assert_eq!(csv_quote("say \"hi\""), "\"say \"\"hi\"\"\""); 711 } 712 713 // -- format_text -- 714 715 #[test] 716 fn test_format_text_no_diff() { 717 let result = DiffResult { 718 headers: vec!["a".into()], 719 key_columns: vec![], 720 added: vec![], 721 removed: vec![], 722 modified: vec![], 723 source_a: SheetSource { 724 file_name: "a.xlsx".into(), 725 sheet_name: "Sheet1".into(), 726 }, 727 source_b: SheetSource { 728 file_name: "b.xlsx".into(), 729 sheet_name: "Sheet1".into(), 730 }, 731 }; 732 assert_eq!(format_text(&result, false), "No differences found.\n"); 733 } 734 735 #[test] 736 fn test_format_text_with_changes() { 737 let result = DiffResult { 738 headers: vec!["id".into(), "name".into()], 739 key_columns: vec!["id".into()], 740 added: vec![DiffRow { 741 values: vec!["3".into(), "Charlie".into()], 742 }], 743 removed: vec![DiffRow { 744 values: vec!["1".into(), "Alice".into()], 745 }], 746 modified: vec![ModifiedRow { 747 key: vec!["2".into()], 748 changes: vec![CellChange { 749 column: "name".into(), 750 old_value: "Bob".into(), 751 new_value: "Robert".into(), 752 }], 753 }], 754 source_a: SheetSource { 755 file_name: "a.xlsx".into(), 756 sheet_name: "Sheet1".into(), 757 }, 758 source_b: SheetSource { 759 file_name: "b.xlsx".into(), 760 sheet_name: "Sheet1".into(), 761 }, 762 }; 763 let text = format_text(&result, false); 764 assert!(text.contains("--- Sheet1 (a.xlsx)")); 765 assert!(text.contains("+++ Sheet1 (b.xlsx)")); 766 assert!(text.contains("Added: 1")); 767 assert!(text.contains("Removed: 1")); 768 assert!(text.contains("Modified: 1")); 769 assert!(text.contains("- id: \"1\" name: \"Alice\"")); 770 assert!(text.contains("+ id: \"3\" name: \"Charlie\"")); 771 assert!(text.contains("~ id: \"2\"")); 772 assert!(text.contains("name: \"Bob\" \u{2192} \"Robert\"")); 773 } 774 775 // -- format_json -- 776 777 #[test] 778 fn test_format_json_structure() { 779 let result = DiffResult { 780 headers: vec!["id".into(), "val".into()], 781 key_columns: vec!["id".into()], 782 added: vec![DiffRow { 783 values: vec!["2".into(), "new".into()], 784 }], 785 removed: vec![], 786 modified: vec![], 787 source_a: SheetSource { 788 file_name: "a.xlsx".into(), 789 sheet_name: "S1".into(), 790 }, 791 source_b: SheetSource { 792 file_name: "b.xlsx".into(), 793 sheet_name: "S1".into(), 794 }, 795 }; 796 let json_str = format_json(&result); 797 let parsed: Value = serde_json::from_str(&json_str).unwrap(); 798 assert_eq!(parsed["added"][0]["id"], "2"); 799 assert_eq!(parsed["added"][0]["val"], "new"); 800 assert!(parsed["removed"].as_array().unwrap().is_empty()); 801 } 802 803 // -- format_csv -- 804 805 #[test] 806 fn test_format_csv_header() { 807 let result = DiffResult { 808 headers: vec!["id".into(), "name".into()], 809 key_columns: vec!["id".into()], 810 added: vec![], 811 removed: vec![], 812 modified: vec![], 813 source_a: SheetSource { 814 file_name: "a.xlsx".into(), 815 sheet_name: "S".into(), 816 }, 817 source_b: SheetSource { 818 file_name: "b.xlsx".into(), 819 sheet_name: "S".into(), 820 }, 821 }; 822 let csv = format_csv(&result); 823 let first_line = csv.lines().next().unwrap(); 824 assert_eq!(first_line, "_status,id,name,_old_id,_old_name"); 825 } 826 }