xl-cli-tools

CLI tools for viewing and editing Excel files
Log | Files | Refs | README | LICENSE

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 }