commit 7427870043564369c655183e6216438a6c4e00d3
Author: Erik Loualiche <eloualic@umn.edu>
Date: Fri, 13 Mar 2026 13:54:22 -0500
feat: scaffold xlcat project with CLI arg parsing
Diffstat:
6 files changed, 5133 insertions(+), 0 deletions(-)
diff --git a/docs/superpowers/plans/2026-03-13-xlcat-plan.md b/docs/superpowers/plans/2026-03-13-xlcat-plan.md
@@ -0,0 +1,2030 @@
+# xlcat Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Rust CLI tool that reads xls/xlsx files and outputs structured, LLM-friendly text, plus a Claude Code `/xls` skill.
+
+**Architecture:** Calamine reads Excel files (sheets, cells, dimensions). Cell data is bridged into Polars DataFrames for type inference and statistics. Output is formatted as markdown tables or CSV. Clap handles CLI parsing. The tool is a single binary with no runtime dependencies.
+
+**Tech Stack:** Rust, calamine (Excel reading), polars (DataFrame/stats), clap (CLI), rust_xlsxwriter (dev-dep for test fixtures)
+
+**Spec:** `docs/superpowers/specs/2026-03-13-xlcat-design.md`
+
+---
+
+## Chunk 1: Foundation
+
+### Task 1: Project Scaffolding
+
+**Files:**
+- Create: `xlcat/Cargo.toml`
+- Create: `xlcat/src/main.rs`
+
+- [ ] **Step 1: Initialize Cargo project**
+
+Run: `cargo init xlcat` from the project root.
+
+- [ ] **Step 2: Set up Cargo.toml with dependencies**
+
+```toml
+[package]
+name = "xlcat"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+calamine = "0.26"
+polars = { version = "0.46", features = ["dtype-date", "dtype-datetime", "dtype-duration", "csv"] }
+clap = { version = "4", features = ["derive"] }
+anyhow = "1"
+
+[dev-dependencies]
+rust_xlsxwriter = "0.82"
+assert_cmd = "2"
+predicates = "3"
+tempfile = "3"
+```
+
+Note: pin polars to 0.46 (latest with stable Rust 1.90 support — 0.53 may
+require nightly). Verify with `cargo check` and adjust if needed.
+
+- [ ] **Step 3: Define CLI args in main.rs**
+
+```rust
+use anyhow::Result;
+use clap::Parser;
+use std::path::PathBuf;
+
+#[derive(Parser, Debug)]
+#[command(name = "xlcat", about = "View Excel files in the terminal")]
+struct Cli {
+ /// Path to .xls or .xlsx file
+ file: PathBuf,
+
+ /// Show only column names and types
+ #[arg(long)]
+ schema: bool,
+
+ /// Show summary statistics
+ #[arg(long)]
+ describe: bool,
+
+ /// Show first N rows
+ #[arg(long)]
+ head: Option<usize>,
+
+ /// Show last N rows
+ #[arg(long)]
+ tail: Option<usize>,
+
+ /// Show all rows (overrides large-file gate)
+ #[arg(long)]
+ all: bool,
+
+ /// Select sheet by name or 0-based index
+ #[arg(long)]
+ sheet: Option<String>,
+
+ /// Large-file threshold (default: 1M). Accepts: 500K, 1M, 10M, 1G
+ #[arg(long, default_value = "1M", value_parser = parse_size)]
+ max_size: u64,
+
+ /// Output as CSV instead of markdown
+ #[arg(long)]
+ csv: bool,
+}
+
+fn parse_size(s: &str) -> Result<u64, String> {
+ let s = s.trim();
+ let (num_part, multiplier) = if s.ends_with('G') || s.ends_with('g') {
+ (&s[..s.len() - 1], 1_073_741_824u64)
+ } else if s.ends_with("GB") || s.ends_with("gb") {
+ (&s[..s.len() - 2], 1_073_741_824u64)
+ } else if s.ends_with('M') || s.ends_with('m') {
+ (&s[..s.len() - 1], 1_048_576u64)
+ } else if s.ends_with("MB") || s.ends_with("mb") {
+ (&s[..s.len() - 2], 1_048_576u64)
+ } else if s.ends_with('K') || s.ends_with('k') {
+ (&s[..s.len() - 1], 1_024u64)
+ } else if s.ends_with("KB") || s.ends_with("kb") {
+ (&s[..s.len() - 2], 1_024u64)
+ } else {
+ (s, 1u64)
+ };
+ let num: f64 = num_part.parse().map_err(|_| format!("Invalid size: {s}"))?;
+ Ok((num * multiplier as f64) as u64)
+}
+
+fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ // Validate flag combinations
+ let mode_count = cli.schema as u8 + cli.describe as u8;
+ if mode_count > 1 {
+ anyhow::bail!("--schema and --describe are mutually exclusive");
+ }
+ if (cli.schema || cli.describe) && (cli.head.is_some() || cli.tail.is_some() || cli.all) {
+ anyhow::bail!("--schema and --describe cannot be combined with --head, --tail, or --all");
+ }
+ if (cli.schema || cli.describe) && cli.csv {
+ anyhow::bail!("--csv can only be used in data mode (not with --schema or --describe)");
+ }
+
+ eprintln!("xlcat: not yet implemented");
+ std::process::exit(1);
+}
+```
+
+- [ ] **Step 4: Verify it compiles**
+
+Run: `cd xlcat && cargo check`
+Expected: compiles with no errors (warnings OK).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add xlcat/
+git commit -m "feat: scaffold xlcat project with CLI arg parsing"
+```
+
+---
+
+### Task 2: Test Fixture Generator
+
+**Files:**
+- Create: `xlcat/tests/fixtures.rs` (helper module)
+- Create: `xlcat/tests/common/mod.rs`
+
+We generate test xlsx files programmatically so we don't commit binaries.
+
+- [ ] **Step 1: Create test helper that generates fixture files**
+
+Create `xlcat/tests/common/mod.rs`:
+
+```rust
+use rust_xlsxwriter::*;
+use std::path::Path;
+
+/// Single sheet, 5 rows of mixed types: string, float, int, bool, date
+pub fn create_simple(path: &Path) {
+ let mut wb = Workbook::new();
+ let ws = wb.add_worksheet_with_name("Data").unwrap();
+
+ // Headers
+ ws.write_string(0, 0, "name").unwrap();
+ ws.write_string(0, 1, "amount").unwrap();
+ ws.write_string(0, 2, "count").unwrap();
+ ws.write_string(0, 3, "active").unwrap();
+
+ // Row 1
+ ws.write_string(1, 0, "Alice").unwrap();
+ ws.write_number(1, 1, 100.50).unwrap();
+ ws.write_number(1, 2, 10.0).unwrap();
+ ws.write_boolean(1, 3, true).unwrap();
+
+ // Row 2
+ ws.write_string(2, 0, "Bob").unwrap();
+ ws.write_number(2, 1, 200.75).unwrap();
+ ws.write_number(2, 2, 20.0).unwrap();
+ ws.write_boolean(2, 3, false).unwrap();
+
+ // Row 3
+ ws.write_string(3, 0, "Charlie").unwrap();
+ ws.write_number(3, 1, 300.00).unwrap();
+ ws.write_number(3, 2, 30.0).unwrap();
+ ws.write_boolean(3, 3, true).unwrap();
+
+ // Row 4
+ ws.write_string(4, 0, "Diana").unwrap();
+ ws.write_number(4, 1, 400.25).unwrap();
+ ws.write_number(4, 2, 40.0).unwrap();
+ ws.write_boolean(4, 3, false).unwrap();
+
+ // Row 5
+ ws.write_string(5, 0, "Eve").unwrap();
+ ws.write_number(5, 1, 500.00).unwrap();
+ ws.write_number(5, 2, 50.0).unwrap();
+ ws.write_boolean(5, 3, true).unwrap();
+
+ wb.save(path).unwrap();
+}
+
+/// 3 sheets: Revenue (4 rows), Expenses (3 rows), Summary (2 rows)
+pub fn create_multi_sheet(path: &Path) {
+ let mut wb = Workbook::new();
+
+ let ws1 = wb.add_worksheet_with_name("Revenue").unwrap();
+ ws1.write_string(0, 0, "region").unwrap();
+ ws1.write_string(0, 1, "amount").unwrap();
+ for i in 1..=4u32 {
+ ws1.write_string(i, 0, &format!("Region {i}")).unwrap();
+ ws1.write_number(i, 1, i as f64 * 1000.0).unwrap();
+ }
+
+ let ws2 = wb.add_worksheet_with_name("Expenses").unwrap();
+ ws2.write_string(0, 0, "category").unwrap();
+ ws2.write_string(0, 1, "amount").unwrap();
+ for i in 1..=3u32 {
+ ws2.write_string(i, 0, &format!("Category {i}")).unwrap();
+ ws2.write_number(i, 1, i as f64 * 500.0).unwrap();
+ }
+
+ let ws3 = wb.add_worksheet_with_name("Summary").unwrap();
+ ws3.write_string(0, 0, "metric").unwrap();
+ ws3.write_string(0, 1, "value").unwrap();
+ ws3.write_string(1, 0, "Total Revenue").unwrap();
+ ws3.write_number(1, 1, 10000.0).unwrap();
+ ws3.write_string(2, 0, "Total Expenses").unwrap();
+ ws3.write_number(2, 1, 3000.0).unwrap();
+
+ wb.save(path).unwrap();
+}
+
+/// Single sheet with 80 rows (to test head/tail adaptive behavior)
+pub fn create_many_rows(path: &Path) {
+ let mut wb = Workbook::new();
+ let ws = wb.add_worksheet_with_name("Data").unwrap();
+
+ ws.write_string(0, 0, "id").unwrap();
+ ws.write_string(0, 1, "value").unwrap();
+
+ for i in 1..=80u32 {
+ ws.write_number(i, 0, i as f64).unwrap();
+ ws.write_number(i, 1, i as f64 * 1.5).unwrap();
+ }
+
+ wb.save(path).unwrap();
+}
+
+/// Single sheet with header row but no data rows
+pub fn create_empty_data(path: &Path) {
+ let mut wb = Workbook::new();
+ let ws = wb.add_worksheet_with_name("Empty").unwrap();
+ ws.write_string(0, 0, "col_a").unwrap();
+ ws.write_string(0, 1, "col_b").unwrap();
+ wb.save(path).unwrap();
+}
+
+/// Completely empty sheet
+pub fn create_empty_sheet(path: &Path) {
+ let mut wb = Workbook::new();
+ wb.add_worksheet_with_name("Blank").unwrap();
+ wb.save(path).unwrap();
+}
+```
+
+- [ ] **Step 2: Verify the helper compiles**
+
+Run: `cargo test --no-run`
+Expected: compiles (no tests to run yet).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xlcat/tests/
+git commit -m "test: add xlsx fixture generators for integration tests"
+```
+
+---
+
+### Task 3: Metadata Module
+
+**Files:**
+- Create: `xlcat/src/metadata.rs`
+- Modify: `xlcat/src/main.rs` (add `mod metadata;`)
+- Create: `xlcat/tests/test_metadata.rs`
+
+- [ ] **Step 1: Write failing tests for metadata**
+
+Create `xlcat/tests/test_metadata.rs`:
+
+```rust
+mod common;
+
+use std::path::PathBuf;
+use tempfile::TempDir;
+
+// We test the binary output since metadata is an internal module.
+// For unit tests, we'll add tests inside the module itself.
+
+#[test]
+fn test_simple_file_metadata_header() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ // For now, just verify the file was created and is non-empty
+ assert!(path.exists());
+ assert!(std::fs::metadata(&path).unwrap().len() > 0);
+}
+```
+
+Run: `cargo test test_simple_file_metadata_header`
+Expected: PASS (this is a sanity check that fixtures work).
+
+- [ ] **Step 2: Implement metadata module**
+
+Create `xlcat/src/metadata.rs`:
+
+```rust
+use anyhow::{Context, Result};
+use calamine::{open_workbook_auto, Reader};
+use std::path::Path;
+
+/// Info about a single sheet (without loading data).
+#[derive(Debug, Clone)]
+pub struct SheetInfo {
+ pub name: String,
+ pub rows: usize, // data rows including header
+ pub cols: usize,
+}
+
+/// Info about the whole workbook file.
+#[derive(Debug)]
+pub struct FileInfo {
+ pub file_size: u64,
+ pub sheets: Vec<SheetInfo>,
+}
+
+/// Read metadata: file size, sheet names, and dimensions.
+/// This loads each sheet's range to get dimensions, but we
+/// don't keep the data in memory.
+pub fn read_file_info(path: &Path) -> Result<FileInfo> {
+ let file_size = std::fs::metadata(path)
+ .with_context(|| format!("Cannot read file: {}", path.display()))?
+ .len();
+
+ let ext = path
+ .extension()
+ .and_then(|e| e.to_str())
+ .map(|e| e.to_lowercase());
+
+ match ext.as_deref() {
+ Some("xlsx") | Some("xls") | Some("xlsb") | Some("xlsm") => {}
+ Some(other) => anyhow::bail!("Expected .xls or .xlsx file, got: .{other}"),
+ None => anyhow::bail!("Expected .xls or .xlsx file, got: no extension"),
+ }
+
+ let mut workbook = open_workbook_auto(path)
+ .with_context(|| format!("Cannot open workbook: {}", path.display()))?;
+
+ let sheet_names: Vec<String> = workbook.sheet_names().to_vec();
+ let mut sheets = Vec::new();
+
+ for name in &sheet_names {
+ let range = workbook
+ .worksheet_range(name)
+ .with_context(|| format!("Cannot read sheet: {name}"))?;
+ let (rows, cols) = range.get_size();
+ sheets.push(SheetInfo {
+ name: name.clone(),
+ rows,
+ cols,
+ });
+ }
+
+ Ok(FileInfo { file_size, sheets })
+}
+
+/// Format file size for display: "245 KB", "1.2 MB", etc.
+pub fn format_file_size(bytes: u64) -> String {
+ if bytes < 1_024 {
+ format!("{bytes} B")
+ } else if bytes < 1_048_576 {
+ format!("{:.0} KB", bytes as f64 / 1_024.0)
+ } else if bytes < 1_073_741_824 {
+ format!("{:.1} MB", bytes as f64 / 1_048_576.0)
+ } else {
+ format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_format_file_size() {
+ assert_eq!(format_file_size(500), "500 B");
+ assert_eq!(format_file_size(2_048), "2 KB");
+ assert_eq!(format_file_size(1_500_000), "1.4 MB");
+ }
+}
+```
+
+- [ ] **Step 3: Add `mod metadata;` to main.rs**
+
+Add at top of `xlcat/src/main.rs`:
+```rust
+mod metadata;
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `cargo test`
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add xlcat/src/metadata.rs xlcat/src/main.rs xlcat/tests/
+git commit -m "feat: add metadata module for file info and sheet dimensions"
+```
+
+---
+
+## Chunk 2: Core Reading and Formatting
+
+### Task 4: Reader Module (calamine → polars)
+
+**Files:**
+- Create: `xlcat/src/reader.rs`
+- Modify: `xlcat/src/main.rs` (add `mod reader;`)
+
+This is the most complex module. It bridges calamine's cell data into
+Polars DataFrames with proper type inference.
+
+- [ ] **Step 1: Write failing unit tests for the reader**
+
+These tests go inside `reader.rs` as `#[cfg(test)]` module. Write the
+implementation file with just stub functions and the tests first.
+
+Create `xlcat/src/reader.rs`:
+
+```rust
+use anyhow::{Context, Result};
+use calamine::{open_workbook_auto, Data, Reader};
+use polars::prelude::*;
+use std::path::Path;
+
+/// Inferred column type from scanning calamine cells.
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum InferredType {
+ Int,
+ Float,
+ String,
+ Bool,
+ DateTime,
+ Empty,
+}
+
+/// Read a single sheet into a Polars DataFrame.
+/// First row is treated as headers.
+pub fn read_sheet(path: &Path, sheet_name: &str) -> Result<DataFrame> {
+ let mut workbook = open_workbook_auto(path)
+ .with_context(|| format!("Cannot open workbook: {}", path.display()))?;
+
+ let range = workbook
+ .worksheet_range(sheet_name)
+ .with_context(|| format!("Cannot read sheet: {sheet_name}"))?;
+
+ range_to_dataframe(&range)
+}
+
+/// Convert a calamine Range into a Polars DataFrame.
+/// First row is treated as column headers.
+fn range_to_dataframe(range: &calamine::Range<Data>) -> Result<DataFrame> {
+ let (total_rows, cols) = range.get_size();
+ if total_rows == 0 || cols == 0 {
+ return Ok(DataFrame::default());
+ }
+
+ let rows: Vec<&[Data]> = range.rows().collect();
+
+ // First row = headers
+ let headers: Vec<String> = rows[0]
+ .iter()
+ .enumerate()
+ .map(|(i, cell)| match cell {
+ Data::String(s) => s.clone(),
+ _ => format!("column_{i}"),
+ })
+ .collect();
+
+ if total_rows == 1 {
+ // Header only, no data
+ let series: Vec<Column> = headers
+ .iter()
+ .map(|name| {
+ Series::new_empty(name.into(), &DataType::Null).into_column()
+ })
+ .collect();
+ return DataFrame::new(series).map_err(Into::into);
+ }
+
+ let data_rows = &rows[1..];
+ let mut columns: Vec<Column> = Vec::with_capacity(cols);
+
+ for col_idx in 0..cols {
+ let cells: Vec<&Data> = data_rows.iter().map(|row| {
+ if col_idx < row.len() { &row[col_idx] } else { &Data::Empty }
+ }).collect();
+
+ let col_type = infer_column_type(&cells);
+ let series = build_series(&headers[col_idx], &cells, col_type)?;
+ columns.push(series.into_column());
+ }
+
+ DataFrame::new(columns).map_err(Into::into)
+}
+
+fn infer_column_type(cells: &[&Data]) -> InferredType {
+ let mut has_int = false;
+ let mut has_float = false;
+ let mut has_string = false;
+ let mut has_bool = false;
+ let mut has_datetime = false;
+
+ for cell in cells {
+ match cell {
+ Data::Int(_) => has_int = true,
+ Data::Float(_) => has_float = true,
+ Data::String(_) => has_string = true,
+ Data::Bool(_) => has_bool = true,
+ Data::DateTime(_) | Data::DateTimeIso(_) => has_datetime = true,
+ Data::Empty | Data::Error(_) => {}
+ Data::Duration(_) | Data::DurationIso(_) => has_float = true,
+ }
+ }
+
+ if has_string {
+ return InferredType::String; // String trumps all
+ }
+ if has_datetime && !has_int && !has_float && !has_bool {
+ return InferredType::DateTime;
+ }
+ if has_bool && !has_int && !has_float {
+ return InferredType::Bool;
+ }
+ if has_float {
+ return InferredType::Float;
+ }
+ if has_int {
+ return InferredType::Int;
+ }
+ InferredType::Empty
+}
+
+fn build_series(name: &str, cells: &[&Data], col_type: InferredType) -> Result<Series> {
+ let pname = PlSmallStr::from(name);
+ match col_type {
+ InferredType::Int => {
+ let values: Vec<Option<i64>> = cells
+ .iter()
+ .map(|c| match c {
+ Data::Int(v) => Some(*v),
+ Data::Float(v) => Some(*v as i64),
+ Data::Empty | Data::Error(_) => None,
+ _ => None,
+ })
+ .collect();
+ Ok(Series::new(pname, &values))
+ }
+ InferredType::Float => {
+ let values: Vec<Option<f64>> = cells
+ .iter()
+ .map(|c| match c {
+ Data::Float(v) => Some(*v),
+ Data::Int(v) => Some(*v as f64),
+ Data::Empty | Data::Error(_) => None,
+ _ => None,
+ })
+ .collect();
+ Ok(Series::new(pname, &values))
+ }
+ InferredType::String => {
+ let values: Vec<Option<String>> = cells
+ .iter()
+ .map(|c| match c {
+ Data::String(s) => Some(s.clone()),
+ Data::Int(v) => Some(v.to_string()),
+ Data::Float(v) => Some(v.to_string()),
+ Data::Bool(b) => Some(b.to_string()),
+ Data::Empty | Data::Error(_) => None,
+ _ => Some(format!("{c:?}")),
+ })
+ .collect();
+ Ok(Series::new(pname, &values))
+ }
+ InferredType::Bool => {
+ let values: Vec<Option<bool>> = cells
+ .iter()
+ .map(|c| match c {
+ Data::Bool(b) => Some(*b),
+ Data::Empty | Data::Error(_) => None,
+ _ => None,
+ })
+ .collect();
+ Ok(Series::new(pname, &values))
+ }
+ InferredType::DateTime => {
+ // Store as f64 (Excel serial dates) for now
+ let values: Vec<Option<f64>> = cells
+ .iter()
+ .map(|c| match c {
+ Data::DateTime(v) => Some(*v),
+ Data::Empty | Data::Error(_) => None,
+ _ => None,
+ })
+ .collect();
+ Ok(Series::new(pname, &values))
+ }
+ InferredType::Empty => {
+ let values: Vec<Option<f64>> = vec![None; cells.len()];
+ Ok(Series::new(pname, &values))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use calamine::Data;
+
+ fn make_range(headers: Vec<&str>, rows: Vec<Vec<Data>>) -> calamine::Range<Data> {
+ let ncols = headers.len();
+ let nrows = rows.len() + 1; // +1 for header
+ let mut range = calamine::Range::new((0, 0), ((nrows - 1) as u32, (ncols - 1) as u32));
+
+ for (j, h) in headers.iter().enumerate() {
+ range.set_value((0, j as u32), Data::String(h.to_string()));
+ }
+ for (i, row) in rows.iter().enumerate() {
+ for (j, cell) in row.iter().enumerate() {
+ range.set_value(((i + 1) as u32, j as u32), cell.clone());
+ }
+ }
+ range
+ }
+
+ #[test]
+ fn test_infer_int_column() {
+ let cells = vec![
+ &Data::Int(1),
+ &Data::Int(2),
+ &Data::Int(3),
+ ];
+ assert_eq!(infer_column_type(&cells), InferredType::Int);
+ }
+
+ #[test]
+ fn test_infer_float_when_mixed_int_float() {
+ let cells = vec![
+ &Data::Int(1),
+ &Data::Float(2.5),
+ ];
+ assert_eq!(infer_column_type(&cells), InferredType::Float);
+ }
+
+ #[test]
+ fn test_infer_string_trumps_all() {
+ let cells = vec![
+ &Data::Int(1),
+ &Data::String("hello".to_string()),
+ ];
+ assert_eq!(infer_column_type(&cells), InferredType::String);
+ }
+
+ #[test]
+ fn test_range_to_dataframe_basic() {
+ let range = make_range(
+ vec!["name", "value"],
+ vec![
+ vec![Data::String("Alice".to_string()), Data::Float(100.0)],
+ vec![Data::String("Bob".to_string()), Data::Float(200.0)],
+ ],
+ );
+ let df = range_to_dataframe(&range).unwrap();
+ assert_eq!(df.shape(), (2, 2));
+ assert_eq!(df.get_column_names(), &["name", "value"]);
+ }
+
+ #[test]
+ fn test_empty_range() {
+ // calamine::Range::new((0,0),(0,0)) creates a 1x1 range, not empty.
+ // Use an empty Range via Default or by creating one with inverted bounds.
+ let range: calamine::Range<Data> = Default::default();
+ let df = range_to_dataframe(&range).unwrap();
+ assert_eq!(df.shape().0, 0);
+ }
+}
+```
+
+- [ ] **Step 2: Add `mod reader;` to main.rs**
+
+- [ ] **Step 3: Run tests**
+
+Run: `cargo test`
+Expected: all tests pass. The `make_range` helper constructs ranges
+in-memory without needing a file.
+
+Note: `calamine::Range::new()` and `set_value()` are public API. If
+they don't exist in the version we pin, adjust to construct ranges
+differently or use file-based tests instead.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add xlcat/src/reader.rs xlcat/src/main.rs
+git commit -m "feat: add reader module — calamine to polars DataFrame bridge"
+```
+
+---
+
+### Task 5: Formatter Module
+
+**Files:**
+- Create: `xlcat/src/formatter.rs`
+- Modify: `xlcat/src/main.rs` (add `mod formatter;`)
+
+- [ ] **Step 1: Implement formatter with inline tests**
+
+Create `xlcat/src/formatter.rs`:
+
+```rust
+use crate::metadata::{FileInfo, SheetInfo};
+use polars::prelude::*;
+use std::fmt::Write;
+
+/// Format the file metadata header.
+pub fn format_header(file_name: &str, info: &FileInfo) -> String {
+ let size = crate::metadata::format_file_size(info.file_size);
+ let mut out = String::new();
+ writeln!(out, "# File: {file_name} ({size})").unwrap();
+ writeln!(out, "# Sheets: {}", info.sheets.len()).unwrap();
+ out
+}
+
+/// Format a sheet's schema (column names and types).
+pub fn format_schema(sheet: &SheetInfo, df: &DataFrame) -> String {
+ let data_rows = if sheet.rows > 0 { sheet.rows - 1 } else { 0 };
+ let mut out = String::new();
+ writeln!(out, "## Sheet: {} ({} rows x {} cols)", sheet.name, data_rows, sheet.cols).unwrap();
+ writeln!(out).unwrap();
+
+ // Column type table
+ writeln!(out, "| Column | Type |").unwrap();
+ writeln!(out, "|--------|------|").unwrap();
+ for col in df.get_columns() {
+ writeln!(out, "| {} | {} |", col.name(), col.dtype()).unwrap();
+ }
+ out
+}
+
+/// Format the sheet listing for multi-sheet files (no data, just schemas).
+pub fn format_sheet_listing(file_name: &str, info: &FileInfo, schemas: &[(&SheetInfo, DataFrame)]) -> String {
+ let mut out = format_header(file_name, info);
+ writeln!(out).unwrap();
+
+ for (sheet, df) in schemas {
+ out.push_str(&format_schema(sheet, df));
+ writeln!(out).unwrap();
+ }
+
+ writeln!(out, "Use --sheet <name|index> to view data.").unwrap();
+ out
+}
+
+/// Format a DataFrame as a markdown table.
+pub fn format_data_table(df: &DataFrame) -> String {
+ let mut out = String::new();
+ let cols = df.get_columns();
+ if cols.is_empty() {
+ return out;
+ }
+
+ // Header row
+ let header: Vec<String> = cols.iter().map(|c| c.name().to_string()).collect();
+ writeln!(out, "| {} |", header.join(" | ")).unwrap();
+
+ // Separator
+ let seps: Vec<String> = header.iter().map(|h| "-".repeat(h.len().max(3))).collect();
+ writeln!(out, "| {} |", seps.join(" | ")).unwrap();
+
+ // Data rows
+ let height = df.height();
+ for i in 0..height {
+ let row: Vec<String> = cols
+ .iter()
+ .map(|c| format_cell(c, i))
+ .collect();
+ writeln!(out, "| {} |", row.join(" | ")).unwrap();
+ }
+
+ out
+}
+
+/// Format data table showing head + tail with omission separator.
+pub fn format_head_tail(
+ df: &DataFrame,
+ head_n: usize,
+ tail_n: usize,
+) -> String {
+ let total = df.height();
+ if total <= head_n + tail_n {
+ return format_data_table(df);
+ }
+
+ let head_df = df.head(Some(head_n));
+ let tail_df = df.tail(Some(tail_n));
+ let omitted = total - head_n - tail_n;
+
+ let mut out = String::new();
+ let cols = df.get_columns();
+
+ // Header row
+ let header: Vec<String> = cols.iter().map(|c| c.name().to_string()).collect();
+ writeln!(out, "| {} |", header.join(" | ")).unwrap();
+ let seps: Vec<String> = header.iter().map(|h| "-".repeat(h.len().max(3))).collect();
+ writeln!(out, "| {} |", seps.join(" | ")).unwrap();
+
+ // Head rows
+ for i in 0..head_df.height() {
+ let row: Vec<String> = head_df.get_columns().iter().map(|c| format_cell(c, i)).collect();
+ writeln!(out, "| {} |", row.join(" | ")).unwrap();
+ }
+
+ // Omission line
+ writeln!(out, "... ({omitted} rows omitted) ...").unwrap();
+
+ // Tail rows
+ for i in 0..tail_df.height() {
+ let row: Vec<String> = tail_df.get_columns().iter().map(|c| format_cell(c, i)).collect();
+ writeln!(out, "| {} |", row.join(" | ")).unwrap();
+ }
+
+ out
+}
+
+/// Format a DataFrame as CSV.
+pub fn format_csv(df: &DataFrame) -> String {
+ let mut buf = Vec::new();
+ CsvWriter::new(&mut buf).finish(&mut df.clone()).unwrap();
+ String::from_utf8(buf).unwrap()
+}
+
+/// Format a single cell value for display.
+fn format_cell(col: &Column, idx: usize) -> String {
+ let val = col.get(idx);
+ match val {
+ Ok(AnyValue::Null) => String::new(),
+ Ok(v) => v.to_string(),
+ Err(_) => String::new(),
+ }
+}
+
+/// Format the empty-sheet message.
+pub fn format_empty_sheet(sheet: &SheetInfo) -> String {
+ if sheet.rows == 0 && sheet.cols == 0 {
+ format!("## Sheet: {} (empty)\n", sheet.name)
+ } else {
+ format!("## Sheet: {} (no data rows)\n", sheet.name)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::metadata::{FileInfo, SheetInfo};
+
+ #[test]
+ fn test_format_header() {
+ let info = FileInfo {
+ file_size: 250_000,
+ sheets: vec![SheetInfo {
+ name: "Sheet1".into(),
+ rows: 100,
+ cols: 5,
+ }],
+ };
+ let out = format_header("test.xlsx", &info);
+ assert!(out.contains("# File: test.xlsx (244 KB)"));
+ assert!(out.contains("# Sheets: 1"));
+ }
+
+ #[test]
+ fn test_format_data_table() {
+ let s1 = Series::new("name".into(), &["Alice", "Bob"]);
+ let s2 = Series::new("value".into(), &[100i64, 200]);
+ let df = DataFrame::new(vec![s1.into_column(), s2.into_column()]).unwrap();
+ let out = format_data_table(&df);
+ assert!(out.contains("| name | value |"));
+ assert!(out.contains("| Alice | 100 |"));
+ }
+
+ #[test]
+ fn test_format_head_tail_small() {
+ // When total rows <= head + tail, show all
+ let s = Series::new("x".into(), &[1i64, 2, 3]);
+ let df = DataFrame::new(vec![s.into_column()]).unwrap();
+ let out = format_head_tail(&df, 25, 25);
+ assert!(!out.contains("omitted"));
+ assert!(out.contains("| 1 |"));
+ assert!(out.contains("| 3 |"));
+ }
+}
+```
+
+- [ ] **Step 2: Add `mod formatter;` to main.rs**
+
+- [ ] **Step 3: Run tests**
+
+Run: `cargo test`
+Expected: all tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add xlcat/src/formatter.rs xlcat/src/main.rs
+git commit -m "feat: add formatter module — markdown table and CSV output"
+```
+
+---
+
+## Chunk 3: Wiring and Modes
+
+### Task 6: Wire Up Main — Data and Schema Modes
+
+**Files:**
+- Modify: `xlcat/src/main.rs`
+- Create: `xlcat/tests/test_integration.rs`
+
+- [ ] **Step 1: Write integration tests**
+
+Create `xlcat/tests/test_integration.rs`:
+
+```rust
+mod common;
+
+use assert_cmd::Command;
+use predicates::prelude::*;
+use tempfile::TempDir;
+
+fn xlcat() -> Command {
+ Command::cargo_bin("xlcat").unwrap()
+}
+
+#[test]
+fn test_simple_file_default() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("# File: simple.xlsx"))
+ .stdout(predicate::str::contains("# Sheets: 1"))
+ .stdout(predicate::str::contains("## Sheet: Data"))
+ .stdout(predicate::str::contains("| name |"))
+ .stdout(predicate::str::contains("| Alice |"));
+}
+
+#[test]
+fn test_schema_mode() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--schema")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ assert!(stdout.contains("| Column | Type |"));
+ assert!(stdout.contains("| name |"));
+ // Schema mode should NOT contain data rows
+ assert!(!stdout.contains("| Alice |"));
+}
+
+#[test]
+fn test_multi_sheet_default_lists_schemas() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("multi.xlsx");
+ common::create_multi_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("# Sheets: 3"))
+ .stdout(predicate::str::contains("## Sheet: Revenue"))
+ .stdout(predicate::str::contains("## Sheet: Expenses"))
+ .stdout(predicate::str::contains("## Sheet: Summary"))
+ .stdout(predicate::str::contains("Use --sheet"));
+}
+
+#[test]
+fn test_multi_sheet_select_by_name() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("multi.xlsx");
+ common::create_multi_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--sheet")
+ .arg("Revenue")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("| region |"))
+ .stdout(predicate::str::contains("| Region 1 |"));
+}
+
+#[test]
+fn test_multi_sheet_select_by_index() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("multi.xlsx");
+ common::create_multi_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--sheet")
+ .arg("1")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("## Sheet: Expenses"));
+}
+
+#[test]
+fn test_head_tail_adaptive() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("rows omitted"));
+}
+
+#[test]
+fn test_head_flag() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--head")
+ .arg("3")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ // Should have header + 3 data rows, no omission
+ assert!(!stdout.contains("omitted"));
+}
+
+#[test]
+fn test_csv_mode() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--csv")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ // CSV mode: no markdown headers
+ assert!(!stdout.contains("# File:"));
+ assert!(stdout.contains("name,"));
+}
+
+#[test]
+fn test_invalid_flag_combo() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--schema")
+ .arg("--head")
+ .arg("10")
+ .assert()
+ .code(2)
+ .stderr(predicate::str::contains("cannot be combined"));
+}
+
+#[test]
+fn test_file_not_found() {
+ xlcat()
+ .arg("/nonexistent/file.xlsx")
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("Cannot"));
+}
+
+#[test]
+fn test_empty_sheet() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("empty.xlsx");
+ common::create_empty_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("empty"));
+}
+
+#[test]
+fn test_all_without_sheet_on_multi() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("multi.xlsx");
+ common::create_multi_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--all")
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("Multiple sheets"));
+}
+```
+
+- [ ] **Step 2: Implement main.rs orchestration**
+
+Replace the stub `main()` in `xlcat/src/main.rs` with full orchestration:
+
+```rust
+use anyhow::{Context, Result};
+use clap::Parser;
+use std::path::PathBuf;
+
+mod formatter;
+mod metadata;
+mod reader;
+
+#[derive(Parser, Debug)]
+#[command(name = "xlcat", about = "View Excel files in the terminal")]
+struct Cli {
+ /// Path to .xls or .xlsx file
+ file: PathBuf,
+
+ /// Show only column names and types
+ #[arg(long)]
+ schema: bool,
+
+ /// Show summary statistics
+ #[arg(long)]
+ describe: bool,
+
+ /// Show first N rows
+ #[arg(long)]
+ head: Option<usize>,
+
+ /// Show last N rows
+ #[arg(long)]
+ tail: Option<usize>,
+
+ /// Show all rows (overrides large-file gate)
+ #[arg(long)]
+ all: bool,
+
+ /// Select sheet by name or 0-based index
+ #[arg(long)]
+ sheet: Option<String>,
+
+ /// Large-file threshold (default: 1M). Accepts: 500K, 1M, 10M, 1G
+ #[arg(long, default_value = "1M", value_parser = parse_size)]
+ max_size: u64,
+
+ /// Output as CSV instead of markdown
+ #[arg(long)]
+ csv: bool,
+}
+
+fn parse_size(s: &str) -> Result<u64, String> {
+ let s = s.trim();
+ let (num_part, multiplier) = if s.ends_with('G') || s.ends_with('g') {
+ (&s[..s.len() - 1], 1_073_741_824u64)
+ } else if s.ends_with("GB") || s.ends_with("gb") {
+ (&s[..s.len() - 2], 1_073_741_824u64)
+ } else if s.ends_with('M') || s.ends_with('m') {
+ (&s[..s.len() - 1], 1_048_576u64)
+ } else if s.ends_with("MB") || s.ends_with("mb") {
+ (&s[..s.len() - 2], 1_048_576u64)
+ } else if s.ends_with('K') || s.ends_with('k') {
+ (&s[..s.len() - 1], 1_024u64)
+ } else if s.ends_with("KB") || s.ends_with("kb") {
+ (&s[..s.len() - 2], 1_024u64)
+ } else {
+ (s, 1u64)
+ };
+ let num: f64 = num_part.parse().map_err(|_| format!("Invalid size: {s}"))?;
+ Ok((num * multiplier as f64) as u64)
+}
+
+fn run(cli: &Cli) -> Result<()> {
+ // Validate flag combinations (exit code 2 errors)
+ let mode_count = cli.schema as u8 + cli.describe as u8;
+ if mode_count > 1 {
+ return Err(ArgError("--schema and --describe are mutually exclusive".into()).into());
+ }
+ if (cli.schema || cli.describe)
+ && (cli.head.is_some() || cli.tail.is_some() || cli.all)
+ {
+ return Err(ArgError(
+ "--schema and --describe cannot be combined with --head, --tail, or --all".into(),
+ ).into());
+ }
+ if (cli.schema || cli.describe) && cli.csv {
+ return Err(ArgError(
+ "--csv can only be used in data mode (not with --schema or --describe)".into(),
+ ).into());
+ }
+
+ let info = metadata::read_file_info(&cli.file)?;
+ let file_name = cli.file.file_name().unwrap().to_string_lossy();
+
+ // Resolve which sheet(s) to operate on
+ let target_sheet = resolve_sheet(&cli, &info)?;
+
+ match target_sheet {
+ SheetTarget::Single(sheet_idx) => {
+ let sheet = &info.sheets[sheet_idx];
+ let df = reader::read_sheet(&cli.file, &sheet.name)?;
+
+ if cli.csv {
+ // Apply row selection before CSV output
+ let selected = apply_row_selection(&cli, &info, &df);
+ print!("{}", formatter::format_csv(&selected));
+ return Ok(());
+ }
+
+ let mut out = formatter::format_header(&file_name, &info);
+
+ if sheet.rows == 0 && sheet.cols == 0 {
+ out.push_str(&formatter::format_empty_sheet(sheet));
+ } else if df.height() == 0 {
+ out.push_str(&formatter::format_schema(sheet, &df));
+ out.push_str("\n(no data rows)\n");
+ } else if cli.schema {
+ out.push_str(&formatter::format_schema(sheet, &df));
+ } else if cli.describe {
+ out.push_str(&formatter::format_schema(sheet, &df));
+ out.push('\n');
+ out.push_str(&formatter::format_describe(&df));
+ } else {
+ out.push_str(&formatter::format_schema(sheet, &df));
+ out.push('\n');
+ out.push_str(&format_data_with_selection(&cli, &info, &df));
+ }
+
+ print!("{out}");
+ }
+ SheetTarget::ListAll => {
+ // Multi-sheet: load all sheets
+ let mut sheet_dfs = Vec::new();
+ for sheet in &info.sheets {
+ if sheet.rows == 0 && sheet.cols == 0 {
+ sheet_dfs.push((sheet, polars::prelude::DataFrame::default()));
+ } else {
+ let df = reader::read_sheet(&cli.file, &sheet.name)?;
+ sheet_dfs.push((sheet, df));
+ }
+ }
+
+ if cli.describe {
+ // --describe on multi-sheet: describe each sheet
+ let mut out = formatter::format_header(&file_name, &info);
+ for (sheet, df) in &sheet_dfs {
+ out.push('\n');
+ out.push_str(&formatter::format_schema(sheet, df));
+ if df.height() > 0 {
+ out.push('\n');
+ out.push_str(&formatter::format_describe(df));
+ }
+ }
+ print!("{out}");
+ } else {
+ // Default: list schemas only
+ let refs: Vec<(&metadata::SheetInfo, polars::prelude::DataFrame)> =
+ sheet_dfs.into_iter().collect();
+ let out = formatter::format_sheet_listing(
+ &file_name,
+ &info,
+ &refs.iter().map(|(s, d)| (*s, d.clone())).collect::<Vec<_>>(),
+ );
+ print!("{out}");
+ }
+ }
+ }
+
+ Ok(())
+}
+
+enum SheetTarget {
+ Single(usize),
+ ListAll,
+}
+
+fn resolve_sheet(cli: &Cli, info: &metadata::FileInfo) -> Result<SheetTarget> {
+ if let Some(ref sheet_arg) = cli.sheet {
+ // Try as index first
+ if let Ok(idx) = sheet_arg.parse::<usize>() {
+ if idx < info.sheets.len() {
+ return Ok(SheetTarget::Single(idx));
+ }
+ anyhow::bail!(
+ "Sheet index {idx} out of range. Available sheets: {}",
+ info.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", ")
+ );
+ }
+ // Try as name
+ if let Some(idx) = info.sheets.iter().position(|s| s.name == *sheet_arg) {
+ return Ok(SheetTarget::Single(idx));
+ }
+ anyhow::bail!(
+ "Sheet '{}' not found. Available sheets: {}",
+ sheet_arg,
+ info.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", ")
+ );
+ }
+
+ if info.sheets.len() == 1 {
+ Ok(SheetTarget::Single(0))
+ } else {
+ // Multi-sheet: check for flags that require a single sheet
+ if cli.all || cli.head.is_some() || cli.tail.is_some() {
+ return Err(ArgError(
+ "Multiple sheets found. Use --sheet to select one, or --schema to see all.".into(),
+ ).into());
+ }
+ if cli.csv {
+ return Err(ArgError(
+ "Multiple sheets found. Use --sheet to select one for CSV output.".into(),
+ ).into());
+ }
+ Ok(SheetTarget::ListAll)
+ }
+}
+
+/// Apply row selection and return the resulting DataFrame.
+/// Used by --csv mode to respect --head/--tail/--all.
+fn apply_row_selection(
+ cli: &Cli,
+ _info: &metadata::FileInfo,
+ df: &polars::prelude::DataFrame,
+) -> polars::prelude::DataFrame {
+ if cli.all {
+ return df.clone();
+ }
+ let total = df.height();
+ let has_explicit_head = cli.head.is_some();
+ let has_explicit_tail = cli.tail.is_some();
+
+ if has_explicit_head && has_explicit_tail {
+ let head_n = cli.head.unwrap();
+ let tail_n = cli.tail.unwrap();
+ if head_n + tail_n >= total {
+ return df.clone();
+ }
+ let head_df = df.head(Some(head_n));
+ let tail_df = df.tail(Some(tail_n));
+ head_df.vstack(&tail_df).unwrap()
+ } else if has_explicit_head {
+ df.head(Some(cli.head.unwrap()))
+ } else if has_explicit_tail {
+ df.tail(Some(cli.tail.unwrap()))
+ } else {
+ df.clone()
+ }
+}
+
+fn format_data_with_selection(
+ cli: &Cli,
+ info: &metadata::FileInfo,
+ df: &polars::prelude::DataFrame,
+) -> String {
+ let total = df.height();
+
+ // Explicit flags
+ if cli.all {
+ return formatter::format_data_table(df);
+ }
+
+ let has_explicit_head = cli.head.is_some();
+ let has_explicit_tail = cli.tail.is_some();
+
+ if has_explicit_head || has_explicit_tail {
+ let head_n = cli.head.unwrap_or(0);
+ let tail_n = cli.tail.unwrap_or(0);
+
+ if head_n + tail_n >= total {
+ return formatter::format_data_table(df);
+ }
+
+ if has_explicit_head && has_explicit_tail {
+ return formatter::format_head_tail(df, head_n, tail_n);
+ } else if has_explicit_head {
+ let head_df = df.head(Some(head_n));
+ return formatter::format_data_table(&head_df);
+ } else {
+ let tail_df = df.tail(Some(tail_n));
+ return formatter::format_data_table(&tail_df);
+ }
+ }
+
+ // Large-file gate (no explicit row flags)
+ if info.file_size > cli.max_size {
+ let head_df = df.head(Some(25));
+ let mut out = formatter::format_data_table(&head_df);
+ out.push_str(&format!(
+ "\nShowing first 25 rows. Use --head N / --tail N / --all for more.\n"
+ ));
+ return out;
+ }
+
+ // Adaptive default
+ if total <= 50 {
+ formatter::format_data_table(df)
+ } else {
+ formatter::format_head_tail(df, 25, 25)
+ }
+}
+
+/// Errors that represent invalid argument combinations (exit code 2).
+#[derive(Debug)]
+struct ArgError(String);
+impl std::fmt::Display for ArgError {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+impl std::error::Error for ArgError {}
+
+fn main() {
+ let cli = Cli::parse();
+ if let Err(e) = run(&cli) {
+ eprintln!("xlcat: {e}");
+ // Exit code 2 for argument validation errors, 1 for runtime errors
+ if e.downcast_ref::<ArgError>().is_some() {
+ std::process::exit(2);
+ }
+ std::process::exit(1);
+ }
+}
+```
+
+Note: This references `formatter::format_describe` which doesn't exist yet.
+Add a stub to `formatter.rs` for now:
+
+```rust
+pub fn format_describe(_df: &DataFrame) -> String {
+ "(describe not yet implemented)\n".to_string()
+}
+```
+
+Also adjust `format_sheet_listing` signature to take owned tuples:
+
+```rust
+pub fn format_sheet_listing(
+ file_name: &str,
+ info: &FileInfo,
+ schemas: &[(&SheetInfo, DataFrame)],
+) -> String {
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `cargo test`
+Expected: integration tests pass. Some may need adjustment based on
+exact output formatting.
+
+- [ ] **Step 4: Fix any test failures and iterate**
+
+Common issues to watch for:
+- File name in output: `format_header` receives just the filename, not path.
+- Column separator widths in markdown tables.
+- Exact match on `# Sheets: 1` vs `# Sheets: 1\n`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add xlcat/
+git commit -m "feat: wire up main orchestration — data, schema, multi-sheet modes"
+```
+
+---
+
+### Task 7: Describe Mode
+
+**Files:**
+- Modify: `xlcat/src/formatter.rs` (replace stub)
+- Add test to: `xlcat/tests/test_integration.rs`
+
+- [ ] **Step 1: Add integration test for describe**
+
+Add to `xlcat/tests/test_integration.rs`:
+
+```rust
+#[test]
+fn test_describe_mode() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--describe")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("count"))
+ .stdout(predicate::str::contains("mean"));
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cargo test test_describe_mode`
+Expected: FAIL — output contains "(describe not yet implemented)".
+
+- [ ] **Step 3: Implement format_describe**
+
+Replace the stub in `xlcat/src/formatter.rs`:
+
+```rust
+/// Format summary statistics for each column.
+pub fn format_describe(df: &DataFrame) -> String {
+ use polars::prelude::*;
+
+ let mut out = String::new();
+ let cols = df.get_columns();
+ if cols.is_empty() {
+ return out;
+ }
+
+ // Build stats rows
+ let stat_names = ["count", "null_count", "mean", "std", "min", "max", "median", "unique"];
+
+ // Header
+ let mut header = vec!["stat".to_string()];
+ header.extend(cols.iter().map(|c| c.name().to_string()));
+ writeln!(out, "| {} |", header.join(" | ")).unwrap();
+ let seps: Vec<String> = header.iter().map(|h| "-".repeat(h.len().max(3))).collect();
+ writeln!(out, "| {} |", seps.join(" | ")).unwrap();
+
+ for stat in &stat_names {
+ let mut row = vec![stat.to_string()];
+ for col in cols {
+ let val = compute_stat(col, stat);
+ row.push(val);
+ }
+ writeln!(out, "| {} |", row.join(" | ")).unwrap();
+ }
+
+ out
+}
+
+fn compute_stat(col: &Column, stat: &str) -> String {
+ let series = col.as_materialized_series();
+ let len = series.len();
+
+ match stat {
+ "count" => len.to_string(),
+ "null_count" => series.null_count().to_string(),
+ "mean" => {
+ if series.dtype().is_numeric() {
+ series
+ .mean()
+ .map(|v| format!("{v:.4}"))
+ .unwrap_or_else(|| "-".into())
+ } else {
+ "-".into()
+ }
+ }
+ "std" => {
+ if series.dtype().is_numeric() {
+ series
+ .std(1) // ddof=1
+ .map(|v| format!("{v:.4}"))
+ .unwrap_or_else(|| "-".into())
+ } else {
+ "-".into()
+ }
+ }
+ "min" => {
+ if series.dtype().is_numeric() {
+ match series.min_reduce() {
+ Ok(v) => v.value().to_string(),
+ Err(_) => "-".into(),
+ }
+ } else {
+ "-".into()
+ }
+ }
+ "max" => {
+ if series.dtype().is_numeric() {
+ match series.max_reduce() {
+ Ok(v) => v.value().to_string(),
+ Err(_) => "-".into(),
+ }
+ } else {
+ "-".into()
+ }
+ }
+ "median" => {
+ if series.dtype().is_numeric() {
+ series
+ .median()
+ .map(|v| format!("{v:.4}"))
+ .unwrap_or_else(|| "-".into())
+ } else {
+ "-".into()
+ }
+ }
+ "unique" => {
+ match series.n_unique() {
+ Ok(n) => n.to_string(),
+ Err(_) => "-".into(),
+ }
+ }
+ _ => "-".into(),
+ }
+}
+```
+
+Note: `series.mean()`, `series.std()`, `series.median()` return `Option<f64>`.
+`series.min_reduce()`, `series.max_reduce()` return `Result<Scalar>`.
+If the Polars version doesn't have these exact signatures, adjust. Check
+with `cargo doc --open` to see available methods.
+
+- [ ] **Step 4: Run tests**
+
+Run: `cargo test`
+Expected: all tests pass including `test_describe_mode`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add xlcat/src/formatter.rs xlcat/tests/
+git commit -m "feat: implement describe mode — summary statistics per column"
+```
+
+---
+
+## Chunk 4: Polish, Errors, and Skill
+
+### Task 8: Large-File Gate and Edge Cases
+
+**Files:**
+- Modify: `xlcat/tests/test_integration.rs`
+
+The large-file gate logic is already wired in Task 6. Here we add
+targeted tests to verify edge cases.
+
+- [ ] **Step 1: Add edge case integration tests**
+
+Add to `xlcat/tests/test_integration.rs`:
+
+```rust
+#[test]
+fn test_large_file_gate_triggers() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ // Set max-size very low so gate triggers
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--max-size")
+ .arg("1K")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Showing first 25 rows"));
+}
+
+#[test]
+fn test_large_file_gate_overridden_by_head() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--max-size")
+ .arg("1K")
+ .arg("--head")
+ .arg("5")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ assert!(!stdout.contains("Showing first 25 rows"));
+}
+
+#[test]
+fn test_large_file_gate_overridden_by_all() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--max-size")
+ .arg("1K")
+ .arg("--all")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ assert!(!stdout.contains("Showing first 25 rows"));
+}
+
+#[test]
+fn test_empty_data_headers_only() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("empty_data.xlsx");
+ common::create_empty_data(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("no data rows"));
+}
+
+#[test]
+fn test_head_and_tail_together() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--head")
+ .arg("3")
+ .arg("--tail")
+ .arg("2")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("omitted"));
+}
+
+#[test]
+fn test_wrong_extension() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("data.csv");
+ std::fs::write(&path, "a,b\n1,2\n").unwrap();
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("Expected .xls or .xlsx"));
+}
+
+#[test]
+fn test_sheet_not_found() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--sheet")
+ .arg("Nonexistent")
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("not found"))
+ .stderr(predicate::str::contains("Data")); // lists available sheets
+}
+
+#[test]
+fn test_exit_code_success() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .assert()
+ .code(0);
+}
+
+#[test]
+fn test_exit_code_runtime_error() {
+ xlcat()
+ .arg("/nonexistent.xlsx")
+ .assert()
+ .code(1);
+}
+
+#[test]
+fn test_exit_code_invalid_args() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--schema")
+ .arg("--describe")
+ .assert()
+ .code(2);
+}
+
+#[test]
+fn test_tail_flag_alone() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--tail")
+ .arg("3")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ assert!(!stdout.contains("omitted"));
+ // Last 3 rows of 80-row data: ids 78, 79, 80
+ assert!(stdout.contains("| 80"));
+}
+
+#[test]
+fn test_csv_respects_head() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("many.xlsx");
+ common::create_many_rows(&path);
+
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--csv")
+ .arg("--head")
+ .arg("3")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ // Header + 3 data rows = 4 lines
+ let lines: Vec<&str> = stdout.trim().lines().collect();
+ assert_eq!(lines.len(), 4);
+}
+
+#[test]
+fn test_head_tail_overlap_shows_all() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("simple.xlsx");
+ common::create_simple(&path);
+
+ // 5 rows, head 3 + tail 3 = 6 > 5, so show all without duplication
+ let output = xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--head")
+ .arg("3")
+ .arg("--tail")
+ .arg("3")
+ .assert()
+ .success();
+
+ let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
+ assert!(!stdout.contains("omitted"));
+ assert!(stdout.contains("Alice"));
+ assert!(stdout.contains("Eve"));
+}
+
+#[test]
+fn test_describe_multi_sheet_no_sheet_flag() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("multi.xlsx");
+ common::create_multi_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--describe")
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("## Sheet: Revenue"))
+ .stdout(predicate::str::contains("## Sheet: Expenses"))
+ .stdout(predicate::str::contains("count"))
+ .stdout(predicate::str::contains("mean"));
+}
+
+#[test]
+fn test_csv_multi_sheet_without_sheet_is_error() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("multi.xlsx");
+ common::create_multi_sheet(&path);
+
+ xlcat()
+ .arg(path.to_str().unwrap())
+ .arg("--csv")
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("Multiple sheets"));
+}
+```
+
+- [ ] **Step 2: Run tests, fix any failures**
+
+Run: `cargo test`
+Expected: all pass.
+
+`clap` returns exit code 2 for its own parse errors (e.g., `--unknown`).
+Our validation errors in `run()` also return exit code 2 via `ArgError`.
+Runtime errors (file not found, corrupt file, etc.) return exit code 1.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add xlcat/tests/
+git commit -m "test: add edge case tests — large file gate, empty sheets, errors"
+```
+
+---
+
+### Task 9: Install Binary and Claude Code Skill
+
+**Files:**
+- Create: Claude Code skill file (location depends on user setup)
+
+- [ ] **Step 1: Build release binary**
+
+Run: `cd xlcat && cargo build --release`
+Expected: binary at `xlcat/target/release/xlcat`.
+
+- [ ] **Step 2: Install to PATH**
+
+Run: `cp xlcat/target/release/xlcat /usr/local/bin/xlcat` (or user's preferred location).
+Verify: `xlcat --help`
+
+- [ ] **Step 3: Smoke test with a real file**
+
+Find or create a real xlsx file and test:
+```bash
+xlcat some_real_file.xlsx
+xlcat some_real_file.xlsx --schema
+xlcat some_real_file.xlsx --describe
+xlcat some_real_file.xlsx --head 5 --tail 5
+xlcat some_real_file.xlsx --csv | head
+```
+
+- [ ] **Step 4: Create Claude Code /xls skill**
+
+Determine where user's Claude Code skills live. Create the skill file:
+
+```markdown
+---
+name: xls
+description: View and analyze Excel (.xls/.xlsx) files using xlcat
+---
+
+Use `xlcat` to examine Excel files. Run commands via the Bash tool.
+
+## Quick reference
+
+| Command | Purpose |
+|---------|---------|
+| `xlcat <file>` | Overview: metadata + schema + first/last 25 rows |
+| `xlcat <file> --schema` | Column names and types only |
+| `xlcat <file> --describe` | Summary statistics per column |
+| `xlcat <file> --sheet <name>` | View a specific sheet |
+| `xlcat <file> --head N` | First N rows |
+| `xlcat <file> --tail N` | Last N rows |
+| `xlcat <file> --head N --tail M` | First N + last M rows |
+| `xlcat <file> --all` | All rows (overrides size limit) |
+| `xlcat <file> --csv` | Raw CSV output for piping |
+| `xlcat <file> --max-size 5M` | Override large-file threshold |
+
+## Workflow
+
+1. Start with `xlcat <file>` to see the overview.
+2. For multi-sheet files, pick a sheet with `--sheet`.
+3. Use `--describe` for statistical analysis.
+4. Use `--head`/`--tail` to zoom into specific regions.
+5. Use `--csv` when you need to pipe data to other tools.
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "feat: add Claude Code /xls skill and build release binary"
+```
+
+---
+
+## Summary
+
+| Task | Component | Key deliverable |
+|------|-----------|----------------|
+| 1 | Scaffolding | Cargo project + CLI args |
+| 2 | Test fixtures | xlsx generators for tests |
+| 3 | Metadata | File info + sheet dimensions |
+| 4 | Reader | calamine → polars DataFrame |
+| 5 | Formatter | Markdown tables + CSV output |
+| 6 | Main wiring | All modes orchestrated |
+| 7 | Describe | Summary statistics |
+| 8 | Edge cases | Large file gate, errors, tests |
+| 9 | Ship | Release binary + /xls skill |
diff --git a/docs/superpowers/specs/2026-03-13-xlcat-design.md b/docs/superpowers/specs/2026-03-13-xlcat-design.md
@@ -0,0 +1,270 @@
+# xlcat — Excel CLI Viewer for LLMs
+
+## Purpose
+
+A Rust CLI tool that reads `.xls` and `.xlsx` files and outputs structured,
+LLM-friendly text to stdout. Paired with a Claude Code `/xls` skill that
+knows how to invoke it. The goal: let Claude Code view and analyze
+spreadsheets without custom Python scripting.
+
+## CLI Interface
+
+```
+xlcat <file> # adaptive default (see below)
+xlcat <file> --schema # column names + types only
+xlcat <file> --describe # summary statistics per column
+xlcat <file> --head 20 # first 20 rows
+xlcat <file> --tail 10 # last 10 rows
+xlcat <file> --head 10 --tail 5 # first 10 + last 5 rows
+xlcat <file> --all # full dump
+xlcat <file> --sheet "Revenue" # select sheet by name or index
+xlcat <file> --max-size 5M # override large-file threshold (default 1MB)
+xlcat <file> --csv # output as CSV instead of markdown table
+```
+
+## Modes
+
+The tool operates in one of three mutually exclusive modes:
+
+| Mode | Triggered by | Output |
+|--------------|----------------|---------------------------------|
+| **data** | (default) | Metadata + schema + data rows |
+| **schema** | `--schema` | Metadata + schema only |
+| **describe** | `--describe` | Metadata + summary statistics |
+
+Flags `--head`, `--tail`, `--all`, and `--csv` apply only in **data** mode.
+Combining `--schema` or `--describe` with row-selection flags is an error.
+
+`--sheet` works in all modes — it selects which sheet to operate on.
+
+## Default Behavior (Data Mode, No Flags)
+
+Every invocation starts with a metadata header:
+
+```
+# File: report.xlsx (245 KB)
+# Sheets: 3
+```
+
+Then, behavior is resolved in this order of precedence:
+
+### 1. Sheet resolution
+- **`--sheet` provided:** operate on that sheet only (treat as single-sheet).
+- **Single sheet in file:** operate on that sheet.
+- **Multiple sheets, no `--sheet`:** print each sheet's name, dimensions,
+ and column schema. No data rows. User selects a sheet with `--sheet`.
+ (This takes priority over all row-selection logic below.)
+
+### 2. Large-file gate (file size on disk)
+- The `--max-size` flag sets the threshold (default: 1 MB).
+- **File exceeds threshold + no explicit row flags:** print schema + first
+ 25 rows only, with a note:
+ `Showing first 25 rows. Use --head N / --tail N / --all for more.`
+- **Explicit flags (`--head`, `--tail`, `--all`) override the gate.** If
+ the user asks for `--head 500` on a large file, they get 500 rows.
+- `--max-size` is intentionally based on file-on-disk size (cheap to check,
+ no parsing needed). It is a rough proxy, not a precise row-count limit.
+
+### 3. Row selection (single sheet resolved, within size gate)
+
+| Flags | Behavior |
+|--------------------|-------------------------------------------------|
+| (none, ≤50 rows) | All rows |
+| (none, >50 rows) | First 25 + last 25 |
+| `--head N` | First N rows |
+| `--tail N` | Last N rows |
+| `--head N --tail M`| First N rows + last M rows |
+| `--all` | All rows (also overrides large-file gate) |
+
+When both `--head` and `--tail` are used together and the file has fewer
+rows than N + M, all rows are shown without duplication.
+
+### `--all` on multi-sheet files without `--sheet`
+`--all` without `--sheet` on a multi-sheet file is an error:
+`Multiple sheets found. Use --sheet to select one, or --schema to see all.`
+
+## Output Formats
+
+### Default: Markdown table
+
+```
+# File: report.xlsx (245 KB)
+# Sheets: 1
+
+## Sheet: Revenue (1,240 rows x 8 cols)
+
+| Column | Type |
+|----------|---------|
+| date | Date |
+| region | String |
+| amount | Float64 |
+| quarter | String |
+
+| date | region | amount | quarter |
+|------------|--------|---------|---------|
+| 2024-01-01 | East | 1234.56 | Q1 |
+| 2024-01-02 | West | 987.00 | Q1 |
+| ... | ... | ... | ... |
+... (1,190 rows omitted) ...
+| 2024-12-30 | East | 1100.00 | Q4 |
+| 2024-12-31 | West | 1250.75 | Q4 |
+```
+
+### `--csv`
+
+Raw CSV to stdout. No metadata header, no schema section. Suitable for
+piping to other tools.
+
+### `--schema`
+
+Column names and inferred types only:
+
+```
+# File: report.xlsx (245 KB)
+# Sheets: 1
+
+## Sheet: Revenue (1,240 rows x 8 cols)
+
+| Column | Type |
+|----------|---------|
+| date | Date |
+| region | String |
+| amount | Float64 |
+| quarter | String |
+```
+
+### `--describe`
+
+Polars `describe()` output — count, null count, mean, min, max, median,
+std for numeric columns; count, null count, unique for string columns.
+Rendered as a markdown table.
+
+`--describe` operates on the full sheet data (not a row subset). On a
+multi-sheet file without `--sheet`, it describes each sheet sequentially.
+
+### Multi-sheet default output example
+
+```
+# File: budget.xlsx (320 KB)
+# Sheets: 3
+
+## Sheet: Revenue (1,240 rows x 4 cols)
+
+| Column | Type |
+|----------|---------|
+| date | Date |
+| region | String |
+| amount | Float64 |
+| quarter | String |
+
+## Sheet: Expenses (890 rows x 5 cols)
+
+| Column | Type |
+|------------|---------|
+| date | Date |
+| department | String |
+| category | String |
+| amount | Float64 |
+| approved | Boolean |
+
+## Sheet: Summary (12 rows x 3 cols)
+
+| Column | Type |
+|----------|---------|
+| quarter | String |
+| revenue | Float64 |
+| expenses | Float64 |
+
+Use --sheet <name|index> to view data.
+```
+
+## Sheet Selection
+
+`--sheet` accepts a sheet name (string) or a 0-based index (integer).
+
+```
+xlcat file.xlsx --sheet "Revenue"
+xlcat file.xlsx --sheet 0
+```
+
+Without `--sheet`, the adaptive multi-sheet vs. single-sheet behavior applies.
+
+## Technology
+
+- **Language:** Rust
+- **Excel reading:** Polars with calamine backend (`polars` crate with
+ `xlsx` feature)
+- **CLI parsing:** `clap` (derive API)
+- **No runtime dependencies** — single compiled binary
+- **Note:** calamine loads the entire sheet into memory. For very large
+ Excel files, memory usage will be proportional to file content regardless
+ of `--head`/`--max-size` flags. This is inherent to the Excel format.
+- **Output is always UTF-8.**
+
+## Project Structure
+
+```
+xlcat/
+├── Cargo.toml
+├── src/
+│ ├── main.rs # CLI arg parsing (clap), orchestration
+│ ├── reader.rs # Excel reading via Polars/calamine
+│ ├── formatter.rs # Markdown table and CSV output formatting
+│ └── metadata.rs # File size checks, sheet listing
+```
+
+## Claude Code Skill
+
+A `/xls` skill that:
+
+1. Accepts a file path argument.
+2. Invokes `xlcat` with appropriate flags.
+3. For large/multi-sheet files, starts with `--schema` to orient, then
+ drills into specific sheets or row ranges as needed.
+4. Guides the LLM to use `--describe` for statistical overview when
+ analyzing data.
+
+## Type Inference
+
+Column types come from Polars' inference when constructing the DataFrame
+from calamine data. Polars infers types from the full column by default.
+Types are displayed using Polars' type names: `String`, `Float64`, `Int64`,
+`Boolean`, `Date`, `Datetime`, `Null`, etc.
+
+For the multi-sheet listing (schema-only, no data loaded), sheet metadata
+(name, dimensions) is read via calamine directly to avoid loading all
+sheets into memory. Column types require reading the sheet into a DataFrame,
+so the schema listing does load each sheet.
+
+## Empty Sheets
+
+- A sheet with 0 rows and 0 columns: print sheet name and `(empty)`.
+- A sheet with a header row but 0 data rows: print the schema table,
+ then `(no data rows)`.
+
+## Exit Codes
+
+| Code | Meaning |
+|------|----------------------------------------------|
+| 0 | Success |
+| 1 | Runtime error (file not found, corrupt, etc.) |
+| 2 | Invalid arguments (bad flag combination) |
+
+All error messages go to stderr. Stdout contains only data output.
+No partial output on error — the tool fails before writing to stdout
+when possible.
+
+## Error Handling
+
+- File not found: clear error message with path.
+- Unsupported format: "Expected .xls or .xlsx file, got: <ext>"
+- Corrupt/unreadable file: surface the underlying Polars/calamine error.
+- Sheet not found: list available sheets in the error message.
+- Invalid flag combination (e.g., `--schema --head 10`): error with usage hint.
+
+## Future Possibilities (Not in Scope)
+
+- SQL query mode (`--sql "SELECT ..."`)
+- Filter expressions (`--filter "amount > 1000"`)
+- Multiple file comparison
+- ODS / Google Sheets support
diff --git a/xlcat/.gitignore b/xlcat/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/xlcat/Cargo.lock b/xlcat/Cargo.lock
@@ -0,0 +1,2734 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "ar_archive_writer"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
+dependencies = [
+ "object",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "argminmax"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "array-init-cursor"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3"
+
+[[package]]
+name = "assert_cmd"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atoi_simd"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf"
+dependencies = [
+ "debug_unsafe",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "calamine"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
+dependencies = [
+ "byteorder",
+ "codepage",
+ "encoding_rs",
+ "log",
+ "quick-xml",
+ "serde",
+ "zip",
+]
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "windows-link",
+]
+
+[[package]]
+name = "chrono-tz"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
+dependencies = [
+ "chrono",
+ "phf",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "codepage"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+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"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "serde",
+ "static_assertions",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+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 = "debug_unsafe"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ethnum"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "fast-float2"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55"
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+ "allocator-api2",
+ "rayon",
+ "serde",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+ "rayon",
+ "serde",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+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"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lz4"
+version = "1.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4"
+dependencies = [
+ "lz4-sys",
+]
+
+[[package]]
+name = "lz4-sys"
+version = "1.11.1+lz4-1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
+[[package]]
+name = "now"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0"
+dependencies = [
+ "chrono",
+]
+
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "planus"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1691dd09e82f428ce8d6310bd6d5da2557c82ff17694d2a32cad7242aea89f"
+dependencies = [
+ "array-init-cursor",
+]
+
+[[package]]
+name = "polars"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72571dde488ecccbe799798bf99ab7308ebdb7cf5d95bcc498dbd5a132f0da4d"
+dependencies = [
+ "getrandom 0.2.17",
+ "polars-arrow",
+ "polars-core",
+ "polars-error",
+ "polars-io",
+ "polars-lazy",
+ "polars-ops",
+ "polars-parquet",
+ "polars-sql",
+ "polars-time",
+ "polars-utils",
+ "version_check",
+]
+
+[[package]]
+name = "polars-arrow"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6611c758d52e799761cc25900666b71552e6c929d88052811bc9daad4b3321a8"
+dependencies = [
+ "ahash",
+ "atoi_simd",
+ "bytemuck",
+ "chrono",
+ "chrono-tz",
+ "dyn-clone",
+ "either",
+ "ethnum",
+ "getrandom 0.2.17",
+ "hashbrown 0.15.5",
+ "itoa",
+ "lz4",
+ "num-traits",
+ "parking_lot",
+ "polars-arrow-format",
+ "polars-error",
+ "polars-schema",
+ "polars-utils",
+ "simdutf8",
+ "streaming-iterator",
+ "strength_reduce",
+ "strum_macros",
+ "version_check",
+ "zstd",
+]
+
+[[package]]
+name = "polars-arrow-format"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b0ef2474af9396b19025b189d96e992311e6a47f90c53cd998b36c4c64b84c"
+dependencies = [
+ "planus",
+ "serde",
+]
+
+[[package]]
+name = "polars-compute"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "332f2547dbb27599a8ffe68e56159f5996ba03d1dad0382ccb62c109ceacdeb6"
+dependencies = [
+ "atoi_simd",
+ "bytemuck",
+ "chrono",
+ "either",
+ "fast-float2",
+ "itoa",
+ "num-traits",
+ "polars-arrow",
+ "polars-error",
+ "polars-utils",
+ "ryu",
+ "strength_reduce",
+ "version_check",
+]
+
+[[package]]
+name = "polars-core"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796d06eae7e6e74ed28ea54a8fccc584ebac84e6cf0e1e9ba41ffc807b169a01"
+dependencies = [
+ "ahash",
+ "bitflags",
+ "bytemuck",
+ "chrono",
+ "chrono-tz",
+ "comfy-table",
+ "either",
+ "hashbrown 0.14.5",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "itoa",
+ "num-traits",
+ "once_cell",
+ "polars-arrow",
+ "polars-compute",
+ "polars-error",
+ "polars-row",
+ "polars-schema",
+ "polars-utils",
+ "rand",
+ "rand_distr",
+ "rayon",
+ "regex",
+ "strum_macros",
+ "thiserror",
+ "version_check",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "polars-error"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d6529cae0d1db5ed690e47de41fac9b35ae0c26d476830c2079f130887b847"
+dependencies = [
+ "polars-arrow-format",
+ "regex",
+ "simdutf8",
+ "thiserror",
+]
+
+[[package]]
+name = "polars-expr"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8e639991a8ad4fb12880ab44bcc3cf44a5703df003142334d9caf86d77d77e7"
+dependencies = [
+ "ahash",
+ "bitflags",
+ "hashbrown 0.15.5",
+ "num-traits",
+ "once_cell",
+ "polars-arrow",
+ "polars-compute",
+ "polars-core",
+ "polars-io",
+ "polars-ops",
+ "polars-plan",
+ "polars-row",
+ "polars-time",
+ "polars-utils",
+ "rand",
+ "rayon",
+]
+
+[[package]]
+name = "polars-io"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719a77e94480f6be090512da196e378cbcbeb3584c6fe1134c600aee906e38ab"
+dependencies = [
+ "ahash",
+ "async-trait",
+ "atoi_simd",
+ "bytes",
+ "chrono",
+ "fast-float2",
+ "futures",
+ "glob",
+ "hashbrown 0.15.5",
+ "home",
+ "itoa",
+ "memchr",
+ "memmap2",
+ "num-traits",
+ "once_cell",
+ "percent-encoding",
+ "polars-arrow",
+ "polars-core",
+ "polars-error",
+ "polars-parquet",
+ "polars-schema",
+ "polars-time",
+ "polars-utils",
+ "rayon",
+ "regex",
+ "ryu",
+ "simdutf8",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "polars-lazy"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0a731a672dfc8ac38c1f73c9a4b2ae38d2fc8ac363bfb64c5f3a3e072ffc5ad"
+dependencies = [
+ "ahash",
+ "bitflags",
+ "chrono",
+ "memchr",
+ "once_cell",
+ "polars-arrow",
+ "polars-core",
+ "polars-expr",
+ "polars-io",
+ "polars-mem-engine",
+ "polars-ops",
+ "polars-pipe",
+ "polars-plan",
+ "polars-stream",
+ "polars-time",
+ "polars-utils",
+ "rayon",
+ "version_check",
+]
+
+[[package]]
+name = "polars-mem-engine"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33442189bcbf2e2559aa7914db3835429030a13f4f18e43af5fba9d1b018cf12"
+dependencies = [
+ "memmap2",
+ "polars-arrow",
+ "polars-core",
+ "polars-error",
+ "polars-expr",
+ "polars-io",
+ "polars-ops",
+ "polars-plan",
+ "polars-time",
+ "polars-utils",
+ "rayon",
+]
+
+[[package]]
+name = "polars-ops"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb83218b0c216104f0076cd1a005128be078f958125f3d59b094ee73d78c18e"
+dependencies = [
+ "ahash",
+ "argminmax",
+ "base64",
+ "bytemuck",
+ "chrono",
+ "chrono-tz",
+ "either",
+ "hashbrown 0.15.5",
+ "hex",
+ "indexmap",
+ "memchr",
+ "num-traits",
+ "once_cell",
+ "polars-arrow",
+ "polars-compute",
+ "polars-core",
+ "polars-error",
+ "polars-schema",
+ "polars-utils",
+ "rayon",
+ "regex",
+ "regex-syntax",
+ "strum_macros",
+ "unicode-normalization",
+ "unicode-reverse",
+ "version_check",
+]
+
+[[package]]
+name = "polars-parquet"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c60ee85535590a38db6c703a21be4cb25342e40f573f070d1e16f9d84a53ac7"
+dependencies = [
+ "ahash",
+ "async-stream",
+ "base64",
+ "bytemuck",
+ "ethnum",
+ "futures",
+ "hashbrown 0.15.5",
+ "num-traits",
+ "polars-arrow",
+ "polars-compute",
+ "polars-error",
+ "polars-parquet-format",
+ "polars-utils",
+ "simdutf8",
+ "streaming-decompression",
+]
+
+[[package]]
+name = "polars-parquet-format"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c025243dcfe8dbc57e94d9f82eb3bef10b565ab180d5b99bed87fd8aea319ce1"
+dependencies = [
+ "async-trait",
+ "futures",
+]
+
+[[package]]
+name = "polars-pipe"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d238fb76698f56e51ddfa89b135e4eda56a4767c6e8859eed0ab78386fcd52"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-queue",
+ "enum_dispatch",
+ "hashbrown 0.15.5",
+ "num-traits",
+ "once_cell",
+ "polars-arrow",
+ "polars-compute",
+ "polars-core",
+ "polars-expr",
+ "polars-io",
+ "polars-ops",
+ "polars-plan",
+ "polars-row",
+ "polars-utils",
+ "rayon",
+ "uuid",
+ "version_check",
+]
+
+[[package]]
+name = "polars-plan"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f03533a93aa66127fcb909a87153a3c7cfee6f0ae59f497e73d7736208da54c"
+dependencies = [
+ "ahash",
+ "bitflags",
+ "bytemuck",
+ "bytes",
+ "chrono",
+ "chrono-tz",
+ "either",
+ "hashbrown 0.15.5",
+ "memmap2",
+ "num-traits",
+ "once_cell",
+ "percent-encoding",
+ "polars-arrow",
+ "polars-compute",
+ "polars-core",
+ "polars-io",
+ "polars-ops",
+ "polars-time",
+ "polars-utils",
+ "rayon",
+ "recursive",
+ "regex",
+ "strum_macros",
+ "version_check",
+]
+
+[[package]]
+name = "polars-row"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bf47f7409f8e75328d7d034be390842924eb276716d0458607be0bddb8cc839"
+dependencies = [
+ "bitflags",
+ "bytemuck",
+ "polars-arrow",
+ "polars-compute",
+ "polars-error",
+ "polars-utils",
+]
+
+[[package]]
+name = "polars-schema"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "416621ae82b84466cf4ff36838a9b0aeb4a67e76bd3065edc8c9cb7da19b1bc7"
+dependencies = [
+ "indexmap",
+ "polars-error",
+ "polars-utils",
+ "version_check",
+]
+
+[[package]]
+name = "polars-sql"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edaab553b90aa4d6743bb538978e1982368acb58a94408d7dd3299cad49c7083"
+dependencies = [
+ "hex",
+ "polars-core",
+ "polars-error",
+ "polars-lazy",
+ "polars-ops",
+ "polars-plan",
+ "polars-time",
+ "polars-utils",
+ "rand",
+ "regex",
+ "serde",
+ "sqlparser",
+]
+
+[[package]]
+name = "polars-stream"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498997b656c779610c1496b3d96a59fe569ef22a5b81ccfe5325cb3df8dff2fd"
+dependencies = [
+ "atomic-waker",
+ "crossbeam-deque",
+ "crossbeam-utils",
+ "futures",
+ "memmap2",
+ "parking_lot",
+ "pin-project-lite",
+ "polars-core",
+ "polars-error",
+ "polars-expr",
+ "polars-io",
+ "polars-mem-engine",
+ "polars-ops",
+ "polars-parquet",
+ "polars-plan",
+ "polars-utils",
+ "rand",
+ "rayon",
+ "recursive",
+ "slotmap",
+ "tokio",
+ "version_check",
+]
+
+[[package]]
+name = "polars-time"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d192efbdab516d28b3fab1709a969e3385bd5cda050b7c9aa9e2502a01fda879"
+dependencies = [
+ "atoi_simd",
+ "bytemuck",
+ "chrono",
+ "chrono-tz",
+ "now",
+ "num-traits",
+ "once_cell",
+ "polars-arrow",
+ "polars-compute",
+ "polars-core",
+ "polars-error",
+ "polars-ops",
+ "polars-utils",
+ "rayon",
+ "regex",
+ "strum_macros",
+]
+
+[[package]]
+name = "polars-utils"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f6c8166a4a7fbc15b87c81645ed9e1f0651ff2e8c96cafc40ac5bf43441a10"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "bytes",
+ "compact_str",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "libc",
+ "memmap2",
+ "num-traits",
+ "once_cell",
+ "polars-error",
+ "rand",
+ "raw-cpuid",
+ "rayon",
+ "stacker",
+ "sysinfo",
+ "version_check",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "predicates"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
+dependencies = [
+ "ar_archive_writer",
+ "cc",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
+dependencies = [
+ "encoding_rs",
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_distr"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
+dependencies = [
+ "num-traits",
+ "rand",
+]
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rayon"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "recursive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e"
+dependencies = [
+ "recursive-proc-macro-impl",
+ "stacker",
+]
+
+[[package]]
+name = "recursive-proc-macro-impl"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rust_xlsxwriter"
+version = "0.82.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d61a82de4e7b30fc427909f2c5aafaada88cc7ae8316edabae435f74341f9278"
+dependencies = [
+ "zip",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "sqlparser"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05a528114c392209b3264855ad491fcce534b94a38771b0a0b97a79379275ce8"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "stacker"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "streaming-decompression"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3"
+dependencies = [
+ "fallible-streaming-iterator",
+]
+
+[[package]]
+name = "streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
+
+[[package]]
+name = "strength_reduce"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+ "memchr",
+ "ntapi",
+ "windows",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-reverse"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+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"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
+ "windows-link",
+ "windows-result 0.4.1",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "xlcat"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "assert_cmd",
+ "calamine",
+ "clap",
+ "polars",
+ "predicates",
+ "rust_xlsxwriter",
+ "tempfile",
+]
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "displaydoc",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "thiserror",
+ "zopfli",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
diff --git a/xlcat/Cargo.toml b/xlcat/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "xlcat"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+calamine = "0.26"
+polars = { version = "0.46", features = ["dtype-date", "dtype-datetime", "dtype-duration", "csv"] }
+clap = { version = "4", features = ["derive"] }
+anyhow = "1"
+
+[dev-dependencies]
+rust_xlsxwriter = "0.82"
+assert_cmd = "2"
+predicates = "3"
+tempfile = "3"
diff --git a/xlcat/src/main.rs b/xlcat/src/main.rs
@@ -0,0 +1,82 @@
+use anyhow::Result;
+use clap::Parser;
+use std::path::PathBuf;
+
+#[derive(Parser, Debug)]
+#[command(name = "xlcat", about = "View Excel files in the terminal")]
+struct Cli {
+ /// Path to .xls or .xlsx file
+ file: PathBuf,
+
+ /// Show only column names and types
+ #[arg(long)]
+ schema: bool,
+
+ /// Show summary statistics
+ #[arg(long)]
+ describe: bool,
+
+ /// Show first N rows
+ #[arg(long)]
+ head: Option<usize>,
+
+ /// Show last N rows
+ #[arg(long)]
+ tail: Option<usize>,
+
+ /// Show all rows (overrides large-file gate)
+ #[arg(long)]
+ all: bool,
+
+ /// Select sheet by name or 0-based index
+ #[arg(long)]
+ sheet: Option<String>,
+
+ /// Large-file threshold (default: 1M). Accepts: 500K, 1M, 10M, 1G
+ #[arg(long, default_value = "1M", value_parser = parse_size)]
+ max_size: u64,
+
+ /// Output as CSV instead of markdown
+ #[arg(long)]
+ csv: bool,
+}
+
+fn parse_size(s: &str) -> Result<u64, String> {
+ let s = s.trim();
+ let (num_part, multiplier) = if s.ends_with('G') || s.ends_with('g') {
+ (&s[..s.len() - 1], 1_073_741_824u64)
+ } else if s.ends_with("GB") || s.ends_with("gb") {
+ (&s[..s.len() - 2], 1_073_741_824u64)
+ } else if s.ends_with('M') || s.ends_with('m') {
+ (&s[..s.len() - 1], 1_048_576u64)
+ } else if s.ends_with("MB") || s.ends_with("mb") {
+ (&s[..s.len() - 2], 1_048_576u64)
+ } else if s.ends_with('K') || s.ends_with('k') {
+ (&s[..s.len() - 1], 1_024u64)
+ } else if s.ends_with("KB") || s.ends_with("kb") {
+ (&s[..s.len() - 2], 1_024u64)
+ } else {
+ (s, 1u64)
+ };
+ let num: f64 = num_part.parse().map_err(|_| format!("Invalid size: {s}"))?;
+ Ok((num * multiplier as f64) as u64)
+}
+
+fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ // Validate flag combinations
+ let mode_count = cli.schema as u8 + cli.describe as u8;
+ if mode_count > 1 {
+ anyhow::bail!("--schema and --describe are mutually exclusive");
+ }
+ if (cli.schema || cli.describe) && (cli.head.is_some() || cli.tail.is_some() || cli.all) {
+ anyhow::bail!("--schema and --describe cannot be combined with --head, --tail, or --all");
+ }
+ if (cli.schema || cli.describe) && cli.csv {
+ anyhow::bail!("--csv can only be used in data mode (not with --schema or --describe)");
+ }
+
+ eprintln!("xlcat: not yet implemented");
+ std::process::exit(1);
+}