commit d39ef250f7c31ff1f4df2dd976bb5a4f78a41ae1
parent 01c053c325aa199b67f50d9dbae23a96a43e3196
Author: Erik Loualiche <eloualic@umn.edu>
Date: Wed, 18 Mar 2026 08:36:29 -0500
docs: add xldiff demo, update README with 2x2 GIF grid and all four tools
- Add demo/old.xlsx + demo/new.xlsx diff pair and xldiff.tape
- Record demo/xldiff.gif showing key-based diff, markdown, and JSON output
- Rewrite README: 2x2 GIF grid, nav bar, quick usage examples before install
- Add xldiff section with examples, sample output, diff modes
- Trim polars features (default-features = false)
- Update create_demo.rs to generate diff pair files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 229 insertions(+), 118 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -460,17 +460,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
-name = "comfy-table"
-version = "7.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
-dependencies = [
- "crossterm",
- "unicode-segmentation",
- "unicode-width",
-]
-
-[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -553,29 +542,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
-name = "crossterm"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
-dependencies = [
- "bitflags",
- "crossterm_winapi",
- "document-features",
- "parking_lot",
- "rustix",
- "winapi",
-]
-
-[[package]]
-name = "crossterm_winapi"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
-dependencies = [
- "winapi",
-]
-
-[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -637,15 +603,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
[[package]]
-name = "document-features"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
-
-[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1105,12 +1062,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
-name = "litrs"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
-
-[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1450,7 +1401,6 @@ dependencies = [
"bytemuck",
"chrono",
"chrono-tz",
- "comfy-table",
"either",
"hashbrown 0.14.5",
"hashbrown 0.15.5",
@@ -2517,12 +2467,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
-name = "unicode-width"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
-
-[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -31,7 +31,7 @@ path = "src/bin/xldiff.rs"
[dependencies]
calamine = "0.26"
-polars = { version = "0.46", features = ["dtype-date", "dtype-datetime", "dtype-duration", "csv"] }
+polars = { version = "0.46", default-features = false, features = ["dtype-datetime", "csv"] }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
umya-spreadsheet = "2"
diff --git a/README.md b/README.md
@@ -1,21 +1,27 @@
<div align="center">
<h1>xl-cli-tools</h1>
-<h3>View and edit Excel files from the command line</h3>
+<h3>View, edit, query, and diff Excel files from the command line</h3>
[](https://claude.ai)
[](LICENSE)
<table>
<tr>
-<td align="center" width="33%"><strong>xlcat</strong> — view</td>
-<td align="center" width="33%"><strong>xlset</strong> — edit</td>
-<td align="center" width="33%"><strong>xlfilter</strong> — query</td>
+<td align="center" width="50%"><strong>xlcat</strong> — view</td>
+<td align="center" width="50%"><strong>xlset</strong> — edit</td>
</tr>
<tr>
<td><img src="demo/xlcat.gif" alt="xlcat demo" /></td>
<td><img src="demo/xlset.gif" alt="xlset demo" /></td>
+</tr>
+<tr>
+<td align="center" width="50%"><strong>xlfilter</strong> — query</td>
+<td align="center" width="50%"><strong>xldiff</strong> — compare</td>
+</tr>
+<tr>
<td><img src="demo/xlfilter.gif" alt="xlfilter demo" /></td>
+<td><img src="demo/xldiff.gif" alt="xldiff demo" /></td>
</tr>
</table>
@@ -23,11 +29,25 @@
***
-Three binaries, no runtime dependencies:
+[**xlcat**](#xlcat--view-excel-files) · [**xlset**](#xlset--edit-excel-cells) · [**xlfilter**](#xlfilter--query-and-filter) · [**xldiff**](#xldiff--compare-two-sheets) · [**Install**](#installation) · [**Claude Code**](#claude-code-integration)
+
+***
+
+Four binaries, no runtime dependencies:
-- **`xlcat`** — view xlsx/xls files as markdown tables or CSV
-- **`xlset`** — modify cells in existing xlsx files, preserving formatting
-- **`xlfilter`** — filter, sort, and query rows from spreadsheets
+```bash
+# View a spreadsheet
+xlcat report.xlsx
+
+# Edit a cell
+xlset report.xlsx B3=42
+
+# Filter rows
+xlfilter data.xlsx --where "Amount>1000" --sort "Amount:desc"
+
+# Diff two files
+xldiff old.xlsx new.xlsx --key ID
+```
## Installation
@@ -37,16 +57,13 @@ Download from [Releases](https://github.com/LouLouLibs/xl-cli-tools/releases):
```bash
# Apple Silicon
-curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlcat-aarch64-apple-darwin -o ~/.local/bin/xlcat
-curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlset-aarch64-apple-darwin -o ~/.local/bin/xlset
-curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlfilter-aarch64-apple-darwin -o ~/.local/bin/xlfilter
-chmod +x ~/.local/bin/xlcat ~/.local/bin/xlset ~/.local/bin/xlfilter
-
-# Intel Mac
-curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlcat-x86_64-apple-darwin -o ~/.local/bin/xlcat
-curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlset-x86_64-apple-darwin -o ~/.local/bin/xlset
-curl -L https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/xlfilter-x86_64-apple-darwin -o ~/.local/bin/xlfilter
-chmod +x ~/.local/bin/xlcat ~/.local/bin/xlset ~/.local/bin/xlfilter
+for tool in xlcat xlset xlfilter xldiff; do
+ curl -L "https://github.com/LouLouLibs/xl-cli-tools/releases/latest/download/${tool}-aarch64-apple-darwin" \
+ -o ~/.local/bin/$tool
+done
+chmod +x ~/.local/bin/xl{cat,set,filter,diff}
+
+# Intel Mac — replace aarch64 with x86_64
```
### From source
@@ -72,13 +89,9 @@ xlcat report.xlsx --describe
# Pick a sheet in a multi-sheet workbook
xlcat report.xlsx --sheet Revenue
-# First 10 rows
+# First 10 rows / last 5 rows / both
xlcat report.xlsx --head 10
-
-# Last 5 rows
xlcat report.xlsx --tail 5
-
-# Both
xlcat report.xlsx --head 10 --tail 5
# All rows (overrides large-file gate)
@@ -120,6 +133,56 @@ xlcat report.xlsx --csv --head 100 > subset.csv
- **Multiple sheets:** lists schemas, pick one with `--sheet`
- **Large file (>1MB):** schema + first 25 rows (override with `--max-size 5M`)
+## xlset — Edit Excel Cells
+
+```bash
+# Set a single cell
+xlset report.xlsx A2=42
+
+# Set multiple cells
+xlset report.xlsx A2=42 B2="hello world" C2=true
+
+# Preserve leading zeros with type tag
+xlset report.xlsx A2:str=07401
+
+# Target a specific sheet
+xlset report.xlsx --sheet Revenue A2=42
+
+# Write to a new file (don't modify original)
+xlset report.xlsx --output modified.xlsx A2=42
+
+# Bulk update from CSV
+xlset report.xlsx --from updates.csv
+
+# Bulk from stdin
+echo "A1,42" | xlset report.xlsx --from -
+```
+
+### Type inference
+
+Values are auto-detected: `42` becomes a number, `true` becomes boolean, `2024-01-15` becomes a date. Override with tags when needed:
+
+| Tag | Effect |
+|-----|--------|
+| `:str` | Force string (`A1:str=07401` preserves leading zero) |
+| `:num` | Force number |
+| `:bool` | Force boolean |
+| `:date` | Force date |
+
+### CSV format for `--from`
+
+```csv
+cell,value
+A1,42
+B2,hello world
+C3:str,07401
+D4,"value with, comma"
+```
+
+### What gets preserved
+
+xlset modifies only the cells you specify. Everything else is untouched: formatting, formulas, charts, conditional formatting, data validation, merged cells, images.
+
## xlfilter — Query and Filter
```bash
@@ -145,9 +208,6 @@ xlfilter data.xlsx --sort "Amount:desc" --limit 10
# Contains filter (case-insensitive)
xlfilter data.xlsx --where "Name~john"
-# Head/tail (applied before filtering)
-xlfilter data.xlsx --head 100 --where "Status=Active"
-
# Skip metadata rows above the real header
xlfilter data.xlsx --skip 2
@@ -173,66 +233,77 @@ xlfilter data.xlsx --sheet Revenue --where "Amount>5000"
Numeric columns compare numerically; string columns compare lexicographically. Row count is printed to stderr.
-## xlset — Edit Excel Cells
+## xldiff — Compare Two Sheets
```bash
-# Set a single cell
-xlset report.xlsx A2=42
+# Positional diff (whole-row comparison)
+xldiff old.xlsx new.xlsx
-# Set multiple cells
-xlset report.xlsx A2=42 B2="hello world" C2=true
+# Key-based diff (match rows by ID, compare cell by cell)
+xldiff old.xlsx new.xlsx --key ID
-# Preserve leading zeros with type tag
-xlset report.xlsx A2:str=07401
+# Composite key
+xldiff old.xlsx new.xlsx --key Date,Ticker
-# Target a specific sheet
-xlset report.xlsx --sheet Revenue A2=42
+# Compare sheets within the same file
+xldiff report.xlsx:Q1 report.xlsx:Q2
-# Write to a new file (don't modify original)
-xlset report.xlsx --output modified.xlsx A2=42
+# Float tolerance (differences <= 0.01 treated as equal)
+xldiff old.xlsx new.xlsx --key ID --tolerance 0.01
-# Bulk update from CSV
-xlset report.xlsx --from updates.csv
+# Only compare specific columns
+xldiff old.xlsx new.xlsx --key ID --cols Name,Salary
-# Bulk from stdin
-echo "A1,42" | xlset report.xlsx --from -
+# Skip metadata rows (different skip per file)
+xldiff file1.xlsx file2.xlsx --skip 3,5
+
+# Output formats
+xldiff old.xlsx new.xlsx --key ID --format markdown
+xldiff old.xlsx new.xlsx --key ID --format json
+xldiff old.xlsx new.xlsx --key ID --format csv
```
-### Type inference
+### Example output
-Values are auto-detected: `42` becomes a number, `true` becomes boolean, `2024-01-15` becomes a date. Override with tags when needed:
+```
+--- Sheet1 (old.xlsx)
++++ Sheet1 (new.xlsx)
+
+Added: 1 | Removed: 1 | Modified: 2
+
+- ID: "3" Name: "Charlie" Department: "Engineering" Salary: "88000"
++ ID: "5" Name: "Eve" Department: "Marketing" Salary: "70000"
+~ ID: "1"
+ Salary: "95000" → "98000"
+~ ID: "2"
+ Department: "Marketing" → "Design"
+ Salary: "72000" → "75000"
+```
-| Tag | Effect |
-|-----|--------|
-| `:str` | Force string (`A1:str=07401` preserves leading zero) |
-| `:num` | Force number |
-| `:bool` | Force boolean |
-| `:date` | Force date |
+### Diff modes
-### CSV format for `--from`
+**Positional (no `--key`):** Every column defines row identity. Reports added/removed rows only.
-```csv
-cell,value
-A1,42
-B2,hello world
-C3:str,07401
-D4,"value with, comma"
-```
+**Key-based (`--key`):** Match rows by key columns, compare remaining columns cell by cell. Reports added, removed, and modified rows with per-cell changes. Supports composite keys, duplicate key detection, and float tolerance.
-### What gets preserved
+### Exit codes (diff convention)
-xlset modifies only the cells you specify. Everything else is untouched: formatting, formulas, charts, conditional formatting, data validation, merged cells, images.
+| Code | Meaning |
+|------|---------|
+| 0 | No differences |
+| 1 | Differences found |
+| 2 | Error |
## Claude Code integration
-Claude Code skills (`/xlcat` and `/xlset`) are available in [claude-skills](https://github.com/LouLouLibs/claude-skills). Claude can view spreadsheets, analyze data, and make targeted edits in conversations.
+Claude Code skills are available in [claude-skills](https://github.com/LouLouLibs/claude-skills). Claude can view spreadsheets, analyze data, filter rows, compare files, and make targeted edits in conversations.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Success |
-| 1 | Runtime error |
+| 1 | Runtime error (xldiff: differences found) |
| 2 | Invalid arguments |
## License
diff --git a/demo/new.xlsx b/demo/new.xlsx
Binary files differ.
diff --git a/demo/old.xlsx b/demo/old.xlsx
Binary files differ.
diff --git a/demo/xldiff.gif b/demo/xldiff.gif
Binary files differ.
diff --git a/demo/xldiff.tape b/demo/xldiff.tape
@@ -0,0 +1,49 @@
+# VHS tape for recording xldiff demo GIF.
+# Usage: vhs demo/xldiff.tape
+#
+# Prerequisites:
+# - xldiff binary built and in PATH
+# - Demo xlsx files created: cargo run --example create_demo
+
+Output demo/xldiff.gif
+
+Set FontSize 14
+Set Width 1100
+Set Height 550
+Set Padding 15
+Set Theme "GruvboxDarkHard"
+
+Set TypingSpeed 80ms
+
+Set Shell "bash"
+
+Hide
+ Type 'export PS1="> "'
+ Enter
+ Type "clear"
+ Enter
+Show
+
+# ─── 1. SHOW THE INPUT FILES ───
+Type "xlcat demo/old.xlsx"
+Enter
+Sleep 3s
+
+Type "xlcat demo/new.xlsx"
+Enter
+Sleep 3s
+
+# ─── 2. KEY-BASED DIFF ───
+Type "xldiff demo/old.xlsx demo/new.xlsx --key ID"
+Enter
+Sleep 4s
+
+# ─── 3. MARKDOWN OUTPUT ───
+Type "xldiff demo/old.xlsx demo/new.xlsx --key ID --format markdown"
+Enter
+Sleep 4s
+
+# ─── 4. JSON OUTPUT ───
+Type "xldiff demo/old.xlsx demo/new.xlsx --key ID --format json"
+Enter
+Sleep 4s
diff --git a/examples/create_demo.rs b/examples/create_demo.rs
@@ -95,4 +95,51 @@ fn main() {
wb.save(dir.join("budget.xlsx")).unwrap();
println!("Created demo/budget.xlsx");
}
+
+ // --- old.xlsx & new.xlsx: diff demo pair ---
+ {
+ let mut wb = Workbook::new();
+ let ws = wb.add_worksheet();
+ ws.set_name("Sheet1").unwrap();
+ ws.write_string(0, 0, "ID").unwrap();
+ ws.write_string(0, 1, "Name").unwrap();
+ ws.write_string(0, 2, "Department").unwrap();
+ ws.write_string(0, 3, "Salary").unwrap();
+ for (i, (id, name, dept, salary)) in [
+ (1, "Alice", "Engineering", 95000.0),
+ (2, "Bob", "Marketing", 72000.0),
+ (3, "Charlie", "Engineering", 88000.0),
+ (4, "Dana", "Sales", 65000.0),
+ ].iter().enumerate() {
+ let row = (i + 1) as u32;
+ ws.write_number(row, 0, *id as f64).unwrap();
+ ws.write_string(row, 1, *name).unwrap();
+ ws.write_string(row, 2, *dept).unwrap();
+ ws.write_number(row, 3, *salary).unwrap();
+ }
+ wb.save(dir.join("old.xlsx")).unwrap();
+ println!("Created demo/old.xlsx");
+
+ let mut wb = Workbook::new();
+ let ws = wb.add_worksheet();
+ ws.set_name("Sheet1").unwrap();
+ ws.write_string(0, 0, "ID").unwrap();
+ ws.write_string(0, 1, "Name").unwrap();
+ ws.write_string(0, 2, "Department").unwrap();
+ ws.write_string(0, 3, "Salary").unwrap();
+ for (i, (id, name, dept, salary)) in [
+ (1, "Alice", "Engineering", 98000.0),
+ (2, "Bob", "Design", 75000.0),
+ (4, "Dana", "Sales", 65000.0),
+ (5, "Eve", "Marketing", 70000.0),
+ ].iter().enumerate() {
+ let row = (i + 1) as u32;
+ ws.write_number(row, 0, *id as f64).unwrap();
+ ws.write_string(row, 1, *name).unwrap();
+ ws.write_string(row, 2, *dept).unwrap();
+ ws.write_number(row, 3, *salary).unwrap();
+ }
+ wb.save(dir.join("new.xlsx")).unwrap();
+ println!("Created demo/new.xlsx");
+ }
}