commit 43de2a04258d574efe2bffd77ec1fe3d63cf7b56
parent 7a1eb0a39f3446420a1ac2282568734bdbf16f3a
Author: Erik Loualiche <eloualic@umn.edu>
Date: Sun, 15 Mar 2026 10:19:00 -0500
chore: clean repo — remove internal plans, add skills
Remove docs/superpowers/ (plans and design specs not intended for
public consumption). Add xlcat and xlset Claude Code skills to
skills/ directory. Rename /xls skill to /xlcat. Add .DS_Store
to .gitignore.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 172 insertions(+), 3753 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,2 +1,3 @@
/target
.claude/
+.DS_Store
diff --git a/Cargo.toml b/Cargo.toml
@@ -7,7 +7,7 @@ license = "MIT"
repository = "https://github.com/LouLouLibs/xl-cli-tools"
keywords = ["excel", "xlsx", "cli", "llm"]
categories = ["command-line-utilities"]
-exclude = ["demo/*.gif", "docs/superpowers/"]
+exclude = ["demo/*.gif"]
[lib]
name = "xlcat"
diff --git a/README.md b/README.md
@@ -167,7 +167,14 @@ xlset modifies only the cells you specify. Everything else is untouched: formatt
## Claude Code integration
-Both tools include Claude Code skills (`/xls` and `/xlset`) for seamless use in conversations. Claude can view spreadsheets, analyze data, and make targeted edits.
+Both tools include Claude Code skills (`/xlcat` and `/xlset`) in the `skills/` directory. To install, symlink them into `~/.claude/skills/`:
+
+```bash
+ln -s "$(pwd)/skills/xlcat" ~/.claude/skills/xlcat
+ln -s "$(pwd)/skills/xlset" ~/.claude/skills/xlset
+```
+
+Claude can then view spreadsheets, analyze data, and make targeted edits in conversations.
## Exit codes
diff --git a/docs/superpowers/plans/2026-03-13-xlcat-plan.md b/docs/superpowers/plans/2026-03-13-xlcat-plan.md
@@ -1,2030 +0,0 @@
-# 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/plans/2026-03-13-xlset-plan.md b/docs/superpowers/plans/2026-03-13-xlset-plan.md
@@ -1,1275 +0,0 @@
-# xlset 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:** Add an `xlset` binary to the xlcat repo that modifies cells in existing xlsx files using umya-spreadsheet, with shared cell-parsing code.
-
-**Architecture:** Restructure the project from a single binary to a library crate with two binary entry points (`xlcat`, `xlset`). Shared code (cell address parsing, type inference) lives in `lib.rs`/`cell.rs`. xlset uses umya-spreadsheet for round-trip read-modify-write. xlcat continues using calamine + polars for reading.
-
-**Tech Stack:** Rust, umya-spreadsheet (xlsx editing), clap (CLI), calamine + polars (xlcat reading, existing)
-
-**Spec:** `docs/superpowers/specs/2026-03-13-xlset-design.md`
-
----
-
-## Chunk 1: Restructure and Cell Module
-
-### Task 1: Restructure to Library + Two Binaries
-
-**Files:**
-- Create: `src/lib.rs`
-- Create: `src/bin/xlcat.rs` (moved from `src/main.rs`)
-- Delete: `src/main.rs`
-- Modify: `Cargo.toml`
-
-The project currently has `src/main.rs` as the single binary entry point
-with `mod formatter; mod metadata; mod reader;`. We need to convert to a
-library crate so both `xlcat` and `xlset` can share code.
-
-- [ ] **Step 1: Create `src/lib.rs`**
-
-```rust
-pub mod cell;
-pub mod formatter;
-pub mod metadata;
-pub mod reader;
-pub mod writer;
-```
-
-Note: `cell` and `writer` don't exist yet. Create stub files so the
-crate compiles:
-
-`src/cell.rs`:
-```rust
-// Cell address parsing and value type inference (implemented in Task 2)
-```
-
-`src/writer.rs`:
-```rust
-// umya-spreadsheet write logic (implemented in Task 3)
-```
-
-- [ ] **Step 2: Move `src/main.rs` to `src/bin/xlcat.rs`**
-
-Copy `src/main.rs` to `src/bin/xlcat.rs`. Then change the module imports
-at the top from:
-
-```rust
-mod formatter;
-mod metadata;
-mod reader;
-```
-
-to:
-
-```rust
-use xlcat::formatter;
-use xlcat::metadata;
-use xlcat::metadata::{FileInfo, SheetInfo};
-use xlcat::reader;
-```
-
-Remove the `use metadata::{FileInfo, SheetInfo};` line (now part of the
-use statement above). The `use polars::prelude::*;` stays — xlcat needs
-it directly.
-
-Then delete `src/main.rs`.
-
-- [ ] **Step 3: Update `Cargo.toml`**
-
-Add library and binary sections, plus umya-spreadsheet dependency:
-
-```toml
-[package]
-name = "xlcat"
-version = "0.2.0"
-edition = "2024"
-
-[lib]
-name = "xlcat"
-path = "src/lib.rs"
-
-[[bin]]
-name = "xlcat"
-path = "src/bin/xlcat.rs"
-
-[[bin]]
-name = "xlset"
-path = "src/bin/xlset.rs"
-
-[dependencies]
-calamine = "0.26"
-polars = { version = "0.46", features = ["dtype-date", "dtype-datetime", "dtype-duration", "csv"] }
-clap = { version = "4", features = ["derive"] }
-anyhow = "1"
-umya-spreadsheet = "2"
-
-[profile.release]
-strip = true
-lto = true
-codegen-units = 1
-panic = "abort"
-opt-level = "z"
-
-[dev-dependencies]
-rust_xlsxwriter = "0.82"
-assert_cmd = "2"
-predicates = "3"
-tempfile = "3"
-```
-
-- [ ] **Step 4: Create stub `src/bin/xlset.rs`**
-
-```rust
-fn main() {
- eprintln!("xlset: not yet implemented");
- std::process::exit(1);
-}
-```
-
-- [ ] **Step 5: Verify both binaries compile and tests pass**
-
-Run: `cargo test`
-Run: `cargo build --bin xlcat`
-Run: `cargo build --bin xlset`
-Expected: all 49 existing tests pass, both binaries compile.
-
-If tests fail because they can't find the `xlcat` binary, check that
-`assert_cmd::Command::cargo_bin("xlcat")` still resolves correctly with
-the new `[[bin]]` layout.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add src/ Cargo.toml
-git commit -m "refactor: restructure to lib + two binaries (xlcat, xlset)"
-```
-
----
-
-### Task 2: Cell Module — A1 Parser and Value Type Inference
-
-**Files:**
-- Create: `src/cell.rs` (replace stub)
-
-This is shared code used by xlset (and potentially xlcat later). No
-external crate dependencies — pure Rust.
-
-- [ ] **Step 1: Implement cell.rs with tests**
-
-Replace the stub `src/cell.rs` with:
-
-```rust
-use std::fmt;
-
-/// A parsed cell reference (e.g., "A1" → col=0, row=0).
-#[derive(Debug, Clone, PartialEq)]
-pub struct CellRef {
- pub col: u32, // 0-based
- pub row: u32, // 0-based
- pub label: String, // original string, uppercased (e.g., "A1")
-}
-
-/// A typed value to write into a cell.
-#[derive(Debug, Clone, PartialEq)]
-pub enum CellValue {
- String(String),
- Integer(i64),
- Float(f64),
- Bool(bool),
- Date { year: i32, month: u32, day: u32 },
- Empty,
-}
-
-/// A full cell assignment: reference + value.
-#[derive(Debug, Clone)]
-pub struct CellAssignment {
- pub cell: CellRef,
- pub value: CellValue,
-}
-
-/// Parse a cell reference like "A1", "Z99", "AA1", "XFD1048576".
-/// Case-insensitive. Returns error if invalid.
-pub fn parse_cell_ref(s: &str) -> Result<CellRef, String> {
- let s = s.trim();
- if s.is_empty() {
- return Err("Empty cell reference".into());
- }
-
- let upper = s.to_uppercase();
- let bytes = upper.as_bytes();
-
- // Split into letter part and digit part
- let letter_end = bytes.iter().position(|b| b.is_ascii_digit())
- .ok_or_else(|| format!("Invalid cell reference: {s} (no row number)"))?;
- if letter_end == 0 {
- return Err(format!("Invalid cell reference: {s} (no column letter)"));
- }
-
- let col_str = &upper[..letter_end];
- let row_str = &upper[letter_end..];
-
- // Parse column: A=0, B=1, ..., Z=25, AA=26, ...
- let col = parse_col(col_str)
- .ok_or_else(|| format!("Invalid column: {col_str}"))?;
- if col > 16383 {
- return Err(format!("Column out of range: {col_str} (max XFD)"));
- }
-
- // Parse row: 1-based in input, 0-based internally
- let row_1based: u32 = row_str.parse()
- .map_err(|_| format!("Invalid row number: {row_str}"))?;
- if row_1based == 0 || row_1based > 1_048_576 {
- return Err(format!("Row out of range: {row_1based} (must be 1-1048576)"));
- }
-
- Ok(CellRef {
- col,
- row: row_1based - 1,
- label: upper,
- })
-}
-
-fn parse_col(s: &str) -> Option<u32> {
- let mut result: u32 = 0;
- for &b in s.as_bytes() {
- if !b.is_ascii_uppercase() {
- return None;
- }
- result = result.checked_mul(26)?.checked_add((b - b'A') as u32 + 1)?;
- }
- Some(result - 1) // convert to 0-based
-}
-
-/// Parse a cell assignment string: "A1=42", "B2:str=hello", etc.
-/// Format: <cellref>[:<type_tag>]=<value>
-pub fn parse_assignment(s: &str) -> Result<CellAssignment, String> {
- let eq_pos = s.find('=')
- .ok_or_else(|| format!("Invalid assignment (no '='): {s}"))?;
-
- let lhs = &s[..eq_pos];
- let rhs = &s[eq_pos + 1..];
-
- // Check for type tag
- let (cell_str, type_tag) = if let Some(colon_pos) = lhs.find(':') {
- (&lhs[..colon_pos], Some(&lhs[colon_pos + 1..]))
- } else {
- (lhs, None)
- };
-
- let cell = parse_cell_ref(cell_str)?;
-
- let value = if let Some(tag) = type_tag {
- parse_value_with_tag(rhs, tag)?
- } else {
- infer_value(rhs)
- };
-
- Ok(CellAssignment { cell, value })
-}
-
-fn parse_value_with_tag(s: &str, tag: &str) -> Result<CellValue, String> {
- match tag.to_lowercase().as_str() {
- "str" => Ok(CellValue::String(s.to_string())),
- "num" => {
- if let Ok(i) = s.parse::<i64>() {
- Ok(CellValue::Integer(i))
- } else if let Ok(f) = s.parse::<f64>() {
- Ok(CellValue::Float(f))
- } else {
- Err(format!("Cannot parse as number: {s}"))
- }
- }
- "bool" => {
- match s.to_lowercase().as_str() {
- "true" | "1" | "yes" => Ok(CellValue::Bool(true)),
- "false" | "0" | "no" => Ok(CellValue::Bool(false)),
- _ => Err(format!("Cannot parse as boolean: {s}")),
- }
- }
- "date" => parse_date(s),
- _ => Err(format!("Unknown type tag: {tag}. Valid tags: str, num, bool, date")),
- }
-}
-
-/// Auto-infer value type from string content.
-pub fn infer_value(s: &str) -> CellValue {
- if s.is_empty() {
- return CellValue::Empty;
- }
- // Boolean
- match s.to_lowercase().as_str() {
- "true" => return CellValue::Bool(true),
- "false" => return CellValue::Bool(false),
- _ => {}
- }
- // Integer (no decimal point)
- if let Ok(i) = s.parse::<i64>() {
- return CellValue::Integer(i);
- }
- // Float
- if let Ok(f) = s.parse::<f64>() {
- return CellValue::Float(f);
- }
- // Date (YYYY-MM-DD)
- if let Ok(cv) = parse_date(s) {
- return cv;
- }
- // String fallback
- CellValue::String(s.to_string())
-}
-
-fn parse_date(s: &str) -> Result<CellValue, String> {
- let parts: Vec<&str> = s.split('-').collect();
- if parts.len() != 3 {
- return Err(format!("Invalid date format: {s} (expected YYYY-MM-DD)"));
- }
- let year: i32 = parts[0].parse().map_err(|_| format!("Invalid year: {}", parts[0]))?;
- let month: u32 = parts[1].parse().map_err(|_| format!("Invalid month: {}", parts[1]))?;
- let day: u32 = parts[2].parse().map_err(|_| format!("Invalid day: {}", parts[2]))?;
- if month < 1 || month > 12 || day < 1 || day > 31 {
- return Err(format!("Invalid date: {s}"));
- }
- Ok(CellValue::Date { year, month, day })
-}
-
-impl fmt::Display for CellRef {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- write!(f, "{}", self.label)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_parse_cell_ref_simple() {
- let r = parse_cell_ref("A1").unwrap();
- assert_eq!(r.col, 0);
- assert_eq!(r.row, 0);
- }
-
- #[test]
- fn test_parse_cell_ref_z26() {
- let r = parse_cell_ref("Z1").unwrap();
- assert_eq!(r.col, 25);
- }
-
- #[test]
- fn test_parse_cell_ref_aa() {
- let r = parse_cell_ref("AA1").unwrap();
- assert_eq!(r.col, 26);
- }
-
- #[test]
- fn test_parse_cell_ref_case_insensitive() {
- let r = parse_cell_ref("a1").unwrap();
- assert_eq!(r.col, 0);
- assert_eq!(r.row, 0);
- assert_eq!(r.label, "A1");
- }
-
- #[test]
- fn test_parse_cell_ref_row_offset() {
- let r = parse_cell_ref("B10").unwrap();
- assert_eq!(r.col, 1);
- assert_eq!(r.row, 9); // 0-based
- }
-
- #[test]
- fn test_parse_cell_ref_invalid() {
- assert!(parse_cell_ref("").is_err());
- assert!(parse_cell_ref("123").is_err());
- assert!(parse_cell_ref("A0").is_err()); // row 0 invalid
- assert!(parse_cell_ref("A").is_err()); // no row
- }
-
- #[test]
- fn test_infer_value_integer() {
- assert_eq!(infer_value("42"), CellValue::Integer(42));
- assert_eq!(infer_value("-5"), CellValue::Integer(-5));
- }
-
- #[test]
- fn test_infer_value_float() {
- assert_eq!(infer_value("3.14"), CellValue::Float(3.14));
- }
-
- #[test]
- fn test_infer_value_bool() {
- assert_eq!(infer_value("true"), CellValue::Bool(true));
- assert_eq!(infer_value("false"), CellValue::Bool(false));
- assert_eq!(infer_value("TRUE"), CellValue::Bool(true));
- }
-
- #[test]
- fn test_infer_value_date() {
- assert_eq!(
- infer_value("2024-01-15"),
- CellValue::Date { year: 2024, month: 1, day: 15 }
- );
- }
-
- #[test]
- fn test_infer_value_string() {
- assert_eq!(infer_value("hello"), CellValue::String("hello".into()));
- }
-
- #[test]
- fn test_infer_leading_zero_becomes_integer() {
- // Leading zeros get lost — this is why :str type tags exist
- assert_eq!(infer_value("07401"), CellValue::Integer(7401));
- }
-
- #[test]
- fn test_infer_value_empty() {
- assert_eq!(infer_value(""), CellValue::Empty);
- }
-
- #[test]
- fn test_parse_assignment_basic() {
- let a = parse_assignment("A1=42").unwrap();
- assert_eq!(a.cell.col, 0);
- assert_eq!(a.cell.row, 0);
- assert_eq!(a.value, CellValue::Integer(42));
- }
-
- #[test]
- fn test_parse_assignment_with_tag() {
- let a = parse_assignment("A1:str=07401").unwrap();
- assert_eq!(a.value, CellValue::String("07401".into()));
- }
-
- #[test]
- fn test_parse_assignment_no_equals() {
- assert!(parse_assignment("A1").is_err());
- }
-
- #[test]
- fn test_parse_assignment_empty_value() {
- let a = parse_assignment("A1=").unwrap();
- assert_eq!(a.value, CellValue::Empty);
- }
-
- #[test]
- fn test_parse_assignment_string_with_spaces() {
- let a = parse_assignment("A1=hello world").unwrap();
- assert_eq!(a.value, CellValue::String("hello world".into()));
- }
-}
-```
-
-- [ ] **Step 2: Run tests**
-
-Run: `cargo test cell::tests`
-Expected: all tests pass.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/cell.rs
-git commit -m "feat: add cell module — A1 parser and value type inference"
-```
-
----
-
-## Chunk 2: Writer and xlset Binary
-
-### Task 3: Writer Module
-
-**Files:**
-- Create: `src/writer.rs` (replace stub)
-
-Uses umya-spreadsheet to open an existing xlsx, modify cells, and save.
-
-- [ ] **Step 1: Implement writer.rs**
-
-Replace the stub `src/writer.rs` with:
-
-```rust
-use anyhow::{Context, Result};
-use std::path::Path;
-use umya_spreadsheet::*;
-
-use crate::cell::{CellAssignment, CellValue};
-
-/// Open an xlsx file, apply cell assignments to the given sheet, and save.
-/// Open an xlsx, apply assignments, save. Returns (count, sheet_name).
-pub fn write_cells(
- input_path: &Path,
- output_path: &Path,
- sheet_selector: &str,
- assignments: &[CellAssignment],
-) -> Result<(usize, String)> {
- // Validate extension
- let ext = input_path
- .extension()
- .and_then(|e| e.to_str())
- .map(|e| e.to_lowercase());
- match ext.as_deref() {
- Some("xlsx") | Some("xlsm") => {}
- Some("xls") => anyhow::bail!("xlset only supports .xlsx files, not .xls"),
- Some(other) => anyhow::bail!("Expected .xlsx file, got: .{other}"),
- None => anyhow::bail!("Expected .xlsx file, got: no extension"),
- }
-
- let mut book = reader::xlsx::read(input_path)
- .with_context(|| format!("Cannot open workbook: {}", input_path.display()))?;
-
- let sheet_names = get_sheet_names(&book);
- let resolved_name = resolve_sheet_name(&sheet_names, sheet_selector)?;
- let sheet = resolve_sheet(&mut book, sheet_selector)?;
-
- let mut count = 0;
- for assignment in assignments {
- apply_assignment(sheet, assignment)?;
- count += 1;
- }
-
- writer::xlsx::write(&book, output_path)
- .with_context(|| format!("Cannot write to: {}", output_path.display()))?;
-
- Ok((count, resolved_name))
-}
-
-fn resolve_sheet_name(sheet_names: &[String], selector: &str) -> Result<String> {
- if selector.is_empty() {
- return Ok(sheet_names.first().cloned().unwrap_or_else(|| "Sheet1".into()));
- }
- if let Some(name) = sheet_names.iter().find(|n| n.as_str() == selector) {
- return Ok(name.clone());
- }
- if let Ok(idx) = selector.parse::<usize>() {
- if idx < sheet_names.len() {
- return Ok(sheet_names[idx].clone());
- }
- }
- Ok(selector.to_string())
-}
-
-fn get_sheet_names(book: &Spreadsheet) -> Vec<String> {
- let mut names = Vec::new();
- for i in 0..book.get_sheet_count() {
- if let Some(sheet) = book.get_sheet(&i) {
- names.push(sheet.get_name().to_string());
- }
- }
- names
-}
-
-fn resolve_sheet<'a>(
- book: &'a mut Spreadsheet,
- selector: &str,
-) -> Result<&'a mut Worksheet> {
- let sheet_names = get_sheet_names(book);
-
- // Empty selector → first sheet
- if selector.is_empty() {
- return book.get_sheet_mut(&0)
- .ok_or_else(|| anyhow::anyhow!("Workbook has no sheets"));
- }
-
- // Try name match first
- if let Some(idx) = sheet_names.iter().position(|n| n == selector) {
- return book.get_sheet_mut(&idx)
- .ok_or_else(|| anyhow::anyhow!("Sheet not found: {selector}"));
- }
-
- // Try 0-based index
- if let Ok(idx) = selector.parse::<usize>() {
- if idx < sheet_names.len() {
- return book.get_sheet_mut(&idx)
- .ok_or_else(|| anyhow::anyhow!("Sheet index {idx} out of range"));
- }
- let available = sheet_names.join(", ");
- anyhow::bail!("Sheet index {idx} out of range. Available sheets: {available}");
- }
-
- let available = sheet_names.join(", ");
- anyhow::bail!("Sheet not found: {selector}. Available sheets: {available}");
-}
-
-fn apply_assignment(sheet: &mut Worksheet, assignment: &CellAssignment) -> Result<()> {
- // Use string-based cell reference (e.g., "A1") per spec.
- // This avoids 0-based/1-based conversion and col/row ordering issues.
- let cell = sheet.get_cell_mut(&assignment.cell.label);
-
- match &assignment.value {
- CellValue::String(s) => {
- // Use set_value_string to prevent auto-conversion of
- // numeric-looking strings (e.g., "07401" → 7401).
- cell.set_value_string(s);
- }
- CellValue::Integer(i) => {
- cell.set_value_number(*i as f64);
- }
- CellValue::Float(f) => {
- cell.set_value_number(*f);
- }
- CellValue::Bool(b) => {
- cell.set_value_bool(*b);
- }
- CellValue::Date { year, month, day } => {
- let serial = date_to_serial(*year, *month, *day);
- cell.set_value_number(serial);
- cell.get_style_mut()
- .get_number_format_mut()
- .set_format_code("yyyy-mm-dd");
- }
- CellValue::Empty => {
- cell.set_value_string("");
- }
- }
-
- Ok(())
-}
-
-/// Convert a date to Excel serial number (days since 1899-12-30).
-fn date_to_serial(year: i32, month: u32, day: u32) -> f64 {
- // Excel serial date: Jan 1, 1900 = 1
- // But Excel incorrectly treats 1900 as a leap year (Lotus 1-2-3 bug),
- // so dates after Feb 28, 1900 are off by 1.
- let y = year as i64;
- let m = month as i64;
- let d = day as i64;
-
- // Using the algorithm for Julian Day Number, then converting to Excel serial
- let a = (14 - m) / 12;
- let y2 = y + 4800 - a;
- let m2 = m + 12 * a - 3;
-
- let jdn = d + (153 * m2 + 2) / 5 + 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 - 32045;
-
- // Excel epoch: Dec 30, 1899 = JDN 2415018.5
- // But Excel serial 1 = Jan 1, 1900
- let excel_epoch_jdn: i64 = 2415020; // Jan 1, 1900 in JDN
- let serial = jdn - excel_epoch_jdn + 1;
-
- // Lotus 1-2-3 bug: Excel thinks Feb 29, 1900 exists.
- // For dates after Feb 28, 1900, add 1.
- if serial > 59 {
- (serial + 1) as f64
- } else {
- serial as f64
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_date_to_serial_known_dates() {
- // Jan 1, 1900 = 1
- assert_eq!(date_to_serial(1900, 1, 1), 1.0);
- // Jan 1, 2024 = 45292
- assert_eq!(date_to_serial(2024, 1, 1), 45292.0);
- }
-}
-```
-
-**Important:** The umya-spreadsheet API may differ from what's shown.
-Key things to verify:
-- `reader::xlsx::read(path)` — reads xlsx file
-- `writer::xlsx::write(&book, path)` — writes xlsx file
-- `book.get_sheet_mut(&index)` — returns `Option<&mut Worksheet>`
-- `sheet.get_cell_mut((col, row))` — may use `(col, row)` or
- `(&col, &row)` tuples. The column and row are 1-based u32.
-- `cell.set_value()`, `cell.set_value_number()`, `cell.set_value_bool()`
-- `cell.get_style_mut().get_number_format_mut().set_format_code()`
-
-If any of these don't match, check `cargo doc -p umya-spreadsheet --open`
-and adjust.
-
-- [ ] **Step 2: Run tests**
-
-Run: `cargo test writer::tests`
-Expected: pass (the date serial test at minimum).
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/writer.rs
-git commit -m "feat: add writer module — umya-spreadsheet cell editing"
-```
-
----
-
-### Task 4: xlset Binary
-
-**Files:**
-- Create: `src/bin/xlset.rs` (replace stub)
-
-- [ ] **Step 1: Implement xlset.rs**
-
-Replace the stub `src/bin/xlset.rs` with:
-
-```rust
-use anyhow::Result;
-use clap::Parser;
-use std::io::{self, BufRead};
-use std::path::PathBuf;
-use std::process;
-
-use xlcat::cell::{self, CellAssignment};
-use xlcat::writer;
-
-#[derive(Parser, Debug)]
-#[command(name = "xlset", about = "Modify cells in Excel (.xlsx) files")]
-struct Cli {
- /// Path to .xlsx file
- file: PathBuf,
-
- /// Cell assignments: A1=42 B2:str=hello
- #[arg(trailing_var_arg = true)]
- assignments: Vec<String>,
-
- /// Target sheet by name or 0-based index (default: first sheet)
- #[arg(long, default_value = "")]
- sheet: String,
-
- /// Write to a different file instead of modifying in-place
- #[arg(long)]
- output: Option<PathBuf>,
-
- /// Read cell assignments from CSV file (or - for stdin)
- #[arg(long, value_name = "FILE")]
- from: Option<String>,
-}
-
-#[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 run(cli: &Cli) -> Result<()> {
- // Validate file exists
- if !cli.file.exists() {
- anyhow::bail!("File not found: {}", cli.file.display());
- }
-
- // Collect assignments from --from CSV
- let mut all_assignments: Vec<CellAssignment> = Vec::new();
-
- if let Some(ref from_path) = cli.from {
- let csv_assignments = read_csv_assignments(from_path)?;
- all_assignments.extend(csv_assignments);
- }
-
- // Collect assignments from positional args
- for arg in &cli.assignments {
- let assignment = cell::parse_assignment(arg)
- .map_err(|e| ArgError(e))?;
- all_assignments.push(assignment);
- }
-
- if all_assignments.is_empty() {
- return Err(ArgError(
- "No cell assignments provided. Use positional args or --from.".into()
- ).into());
- }
-
- // Determine output path
- let output_path = cli.output.as_ref().unwrap_or(&cli.file);
-
- // Write cells
- let (count, sheet_name) = writer::write_cells(
- &cli.file,
- output_path,
- &cli.sheet,
- &all_assignments,
- )?;
-
- let file_name = cli.file.file_name()
- .map(|s| s.to_string_lossy().to_string())
- .unwrap_or_else(|| cli.file.display().to_string());
-
- eprintln!("xlset: updated {count} cells in {sheet_name} ({file_name})");
- Ok(())
-}
-
-fn read_csv_assignments(path: &str) -> Result<Vec<CellAssignment>> {
- let reader: Box<dyn BufRead> = if path == "-" {
- Box::new(io::stdin().lock())
- } else {
- let file = std::fs::File::open(path)
- .map_err(|e| anyhow::anyhow!("Cannot open CSV file {path}: {e}"))?;
- Box::new(io::BufReader::new(file))
- };
-
- let mut assignments = Vec::new();
- let mut line_num = 0;
-
- for line_result in reader.lines() {
- line_num += 1;
- let line = line_result?;
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
-
- // Parse CSV line: cell,value (simple split on first comma)
- // For RFC 4180 quoting, handle quoted values
- let (cell_part, value_part) = parse_csv_line(line, line_num)?;
-
- // Skip header row: if first field doesn't parse as a cell ref
- if line_num == 1 {
- // Check if cell_part looks like a valid cell reference (possibly with type tag)
- let cell_str = if let Some(colon) = cell_part.find(':') {
- &cell_part[..colon]
- } else {
- &cell_part
- };
- if cell::parse_cell_ref(cell_str).is_err() {
- continue; // skip header
- }
- }
-
- let assignment_str = format!("{cell_part}={value_part}");
- let assignment = cell::parse_assignment(&assignment_str)
- .map_err(|e| anyhow::anyhow!("Error on line {line_num}: {e}"))?;
- assignments.push(assignment);
- }
-
- Ok(assignments)
-}
-
-/// Parse a CSV line into (cell, value), handling RFC 4180 quoting.
-fn parse_csv_line(line: &str, line_num: usize) -> Result<(String, String)> {
- // Find the first comma not inside quotes
- let mut in_quotes = false;
- let mut comma_pos = None;
-
- for (i, ch) in line.char_indices() {
- match ch {
- '"' => in_quotes = !in_quotes,
- ',' if !in_quotes => {
- comma_pos = Some(i);
- break;
- }
- _ => {}
- }
- }
-
- let comma_pos = comma_pos
- .ok_or_else(|| anyhow::anyhow!("Error on line {line_num}: expected cell,value format"))?;
-
- let cell_part = line[..comma_pos].trim().to_string();
- let mut value_part = line[comma_pos + 1..].trim().to_string();
-
- // Unquote the value if it's quoted
- if value_part.starts_with('"') && value_part.ends_with('"') && value_part.len() >= 2 {
- value_part = value_part[1..value_part.len() - 1].replace("\"\"", "\"");
- }
-
- Ok((cell_part, value_part))
-}
-
-fn main() {
- let cli = Cli::parse();
- if let Err(err) = run(&cli) {
- if err.downcast_ref::<ArgError>().is_some() {
- eprintln!("xlset: {err}");
- process::exit(2);
- }
- eprintln!("xlset: {err}");
- process::exit(1);
- }
-}
-```
-
-**Important notes on the CLI parsing:**
-- `trailing_var_arg = true` on the `assignments` field tells clap to
- collect all remaining positional args. Verify this works — if not,
- try `#[arg(num_args = 0..)]` instead.
-- The sheet name in the confirmation message should come from the actual
- sheet, not just the --sheet arg. The stub uses `cli.sheet` but ideally
- should resolve to the actual sheet name. Adjust if needed.
-
-- [ ] **Step 2: Verify it compiles**
-
-Run: `cargo build --bin xlset`
-Expected: compiles.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/bin/xlset.rs
-git commit -m "feat: add xlset binary — Excel cell writer CLI"
-```
-
----
-
-## Chunk 3: Integration Tests and Skill
-
-### Task 5: Integration Tests
-
-**Files:**
-- Create: `tests/test_xlset.rs`
-
-- [ ] **Step 1: Create integration tests**
-
-```rust
-mod common;
-
-use assert_cmd::Command;
-use predicates::prelude::*;
-use tempfile::TempDir;
-
-fn xlset() -> Command {
- Command::cargo_bin("xlset").unwrap()
-}
-
-fn xlcat() -> Command {
- Command::cargo_bin("xlcat").unwrap()
-}
-
-#[test]
-fn test_set_single_cell() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- common::create_simple(&path);
-
- // Set cell A2 to "Modified"
- xlset()
- .arg(path.to_str().unwrap())
- .arg("A2=Modified")
- .assert()
- .success()
- .stderr(predicate::str::contains("updated 1 cells"));
-
- // Verify with xlcat
- xlcat()
- .arg(path.to_str().unwrap())
- .assert()
- .success()
- .stdout(predicate::str::contains("Modified"));
-}
-
-#[test]
-fn test_set_multiple_cells() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- common::create_simple(&path);
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("A2=Changed")
- .arg("B2=999")
- .assert()
- .success()
- .stderr(predicate::str::contains("updated 2 cells"));
-
- xlcat()
- .arg(path.to_str().unwrap())
- .assert()
- .success()
- .stdout(predicate::str::contains("Changed"))
- .stdout(predicate::str::contains("999"));
-}
-
-#[test]
-fn test_set_with_type_tag() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- common::create_simple(&path);
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("A2:str=07401")
- .assert()
- .success();
-
- xlcat()
- .arg(path.to_str().unwrap())
- .assert()
- .success()
- .stdout(predicate::str::contains("07401"));
-}
-
-#[test]
-fn test_set_with_output_file() {
- let dir = TempDir::new().unwrap();
- let src = dir.path().join("source.xlsx");
- let dst = dir.path().join("output.xlsx");
- common::create_simple(&src);
-
- xlset()
- .arg(src.to_str().unwrap())
- .arg("--output")
- .arg(dst.to_str().unwrap())
- .arg("A2=New")
- .assert()
- .success();
-
- // Output file has the change
- xlcat()
- .arg(dst.to_str().unwrap())
- .assert()
- .success()
- .stdout(predicate::str::contains("New"));
-
- // Source file unchanged
- xlcat()
- .arg(src.to_str().unwrap())
- .assert()
- .success()
- .stdout(predicate::str::contains("Alice")); // original value
-}
-
-#[test]
-fn test_set_with_sheet_selection() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- common::create_multi_sheet(&path);
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("--sheet")
- .arg("Expenses")
- .arg("A2=Modified")
- .assert()
- .success();
-
- xlcat()
- .arg(path.to_str().unwrap())
- .arg("--sheet")
- .arg("Expenses")
- .assert()
- .success()
- .stdout(predicate::str::contains("Modified"));
-}
-
-#[test]
-fn test_set_from_csv() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- let csv = dir.path().join("updates.csv");
- common::create_simple(&path);
-
- std::fs::write(&csv, "cell,value\nA2,Updated\nB2,999\n").unwrap();
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("--from")
- .arg(csv.to_str().unwrap())
- .assert()
- .success()
- .stderr(predicate::str::contains("updated 2 cells"));
-
- xlcat()
- .arg(path.to_str().unwrap())
- .assert()
- .success()
- .stdout(predicate::str::contains("Updated"));
-}
-
-#[test]
-fn test_set_from_csv_no_header() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- let csv = dir.path().join("updates.csv");
- common::create_simple(&path);
-
- // No header — first line starts with valid cell ref
- std::fs::write(&csv, "A2,Updated\nB2,999\n").unwrap();
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("--from")
- .arg(csv.to_str().unwrap())
- .assert()
- .success()
- .stderr(predicate::str::contains("updated 2 cells"));
-}
-
-#[test]
-fn test_error_no_assignments() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- common::create_simple(&path);
-
- xlset()
- .arg(path.to_str().unwrap())
- .assert()
- .code(2)
- .stderr(predicate::str::contains("No cell assignments"));
-}
-
-#[test]
-fn test_error_file_not_found() {
- xlset()
- .arg("/nonexistent.xlsx")
- .arg("A1=42")
- .assert()
- .code(1)
- .stderr(predicate::str::contains("not found"));
-}
-
-#[test]
-fn test_error_bad_cell_ref() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xlsx");
- common::create_simple(&path);
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("ZZZZZ1=42")
- .assert()
- .failure();
-}
-
-#[test]
-fn test_error_xls_not_supported() {
- let dir = TempDir::new().unwrap();
- let path = dir.path().join("test.xls");
- std::fs::write(&path, "fake").unwrap();
-
- xlset()
- .arg(path.to_str().unwrap())
- .arg("A1=42")
- .assert()
- .failure()
- .stderr(predicate::str::contains("only supports .xlsx"));
-}
-```
-
-- [ ] **Step 2: Run tests**
-
-Run: `cargo test test_xlset`
-Expected: all pass. If tests fail, debug:
-- Check umya-spreadsheet API matches (method names, argument types)
-- Check that `create_simple` fixtures produce valid xlsx that umya can read
-- Check cell coordinate order (umya uses (col, row) not (row, col))
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add tests/test_xlset.rs
-git commit -m "test: add xlset integration tests"
-```
-
----
-
-### Task 6: Claude Code /xlset Skill
-
-**Files:**
-- Create: `~/.claude/skills/xlset/SKILL.md`
-
-- [ ] **Step 1: Create the skill**
-
-```markdown
----
-name: xlset
-description: Modify cells in Excel (.xlsx) files using xlset. Use when the user asks to edit, update, change, or write values to an Excel spreadsheet, or when you need to programmatically update cells in an xlsx file.
----
-
-# xlset — Excel Cell Writer
-
-Modify cells in existing .xlsx files. Preserves formatting, formulas,
-and all structure it doesn't touch.
-
-## Tool Location
-
-\```
-/Users/loulou/.local/bin/xlset
-\```
-
-## Quick Reference
-
-\```bash
-# Set a single cell
-xlset file.xlsx A1=42
-
-# Set multiple cells
-xlset file.xlsx A1=42 B2="hello world" C3=true
-
-# Force type with tag (e.g., preserve leading zero)
-xlset file.xlsx A1:str=07401
-
-# Target a specific sheet
-xlset file.xlsx --sheet Revenue A1=42
-
-# Write to a new file (don't modify original)
-xlset file.xlsx --output new.xlsx A1=42
-
-# Bulk update from CSV
-xlset file.xlsx --from updates.csv
-
-# Bulk from stdin
-echo "A1,42" | xlset file.xlsx --from -
-\```
-
-## Type Inference
-
-Values are auto-detected:
-- `42` → integer, `3.14` → float
-- `true`/`false` → boolean
-- `2024-01-15` → date
-- Everything else → string
-
-Override with tags: `:str`, `:num`, `:bool`, `:date`
-
-## CSV Format
-
-\```csv
-cell,value
-A1,42
-B2,hello
-C3:str,07401
-\```
-
-## Exit Codes
-
-| Code | Meaning |
-|------|---------|
-| 0 | Success |
-| 1 | Runtime error |
-| 2 | Invalid arguments |
-
-## Workflow
-
-1. Use `xlcat file.xlsx` first to see current content
-2. Use `xlset` to modify cells
-3. Use `xlcat` again to verify changes
-```
-
-- [ ] **Step 2: Build release binaries and install**
-
-```bash
-cargo build --release
-cp target/release/xlcat ~/.local/bin/xlcat
-cp target/release/xlset ~/.local/bin/xlset
-```
-
-- [ ] **Step 3: Smoke test**
-
-```bash
-xlset --help
-# Create a test file, modify it, verify
-```
-
-- [ ] **Step 4: Commit code changes**
-
-```bash
-git add -A
-git commit -m "feat: add xlset binary and Claude Code skill"
-```
-
----
-
-## Summary
-
-| Task | Component | Key deliverable |
-|------|-----------|----------------|
-| 1 | Restructure | lib.rs + two binaries |
-| 2 | Cell module | A1 parser, type inference, shared code |
-| 3 | Writer module | umya-spreadsheet read-modify-write |
-| 4 | xlset binary | CLI, CSV parsing, orchestration |
-| 5 | Integration tests | 11 tests verifying xlset end-to-end |
-| 6 | Skill + release | /xlset skill, installed binaries |
diff --git a/docs/superpowers/specs/2026-03-13-xlcat-design.md b/docs/superpowers/specs/2026-03-13-xlcat-design.md
@@ -1,270 +0,0 @@
-# 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/docs/superpowers/specs/2026-03-13-xlset-design.md b/docs/superpowers/specs/2026-03-13-xlset-design.md
@@ -1,176 +0,0 @@
-# xlset — Excel Cell Writer
-
-## Purpose
-
-A Rust CLI tool that modifies cells in existing `.xlsx` files using
-`umya-spreadsheet`. Preserves all formatting, formulas, charts, and
-structure it doesn't touch. Lives in the same repo as `xlcat` (the
-read-only viewer) but is a separate binary.
-
-## CLI Interface
-
-```
-xlset <file> <cell>=<value> [<cell>=<value> ...]
-xlset <file> --from <csv-file|->
-xlset <file> --sheet "Revenue" A1=42 B2="hello"
-xlset <file> --output other.xlsx A1=42
-```
-
-### Flags
-
-| Flag | Purpose |
-|------|---------|
-| `--sheet <name\|index>` | Target sheet (default: first sheet). Name or 0-based index. |
-| `--output <path>` | Write to a new file instead of modifying in-place. |
-| `--from <path\|->` | Read cell assignments from a CSV file or stdin (`-`). |
-
-## Cell Assignment Syntax
-
-Positional arguments after the file path are cell assignments:
-
-```
-A1=42 # auto-infer: integer
-B2=3.14 # auto-infer: float
-C3=true # auto-infer: boolean
-D4=2024-01-15 # auto-infer: date
-E5=hello world # auto-infer: string
-F6:str=07401 # explicit: force string (preserves leading zero)
-G7:num=42 # explicit: force number
-H8:bool=1 # explicit: force boolean
-I9:date=2024-01-15 # explicit: force date
-```
-
-### Type Tags
-
-Optional type tags override auto-inference: `:str`, `:num`, `:bool`, `:date`.
-Placed between the cell reference and the `=` sign.
-
-### Auto-Inference Rules (in order)
-
-1. If explicit type tag is present → use that type.
-2. `true` or `false` (case-insensitive) → boolean.
-3. Parseable as `i64` (no decimal point) → integer (stored as f64 in Excel).
-4. Parseable as `f64` → float.
-5. Matches `YYYY-MM-DD` pattern → date (stored as Excel serial number
- with `yyyy-mm-dd` format code applied to the cell).
-6. Empty value after `=` (e.g., `A1=`) → empty string.
-7. Everything else → string.
-
-## `--from` CSV Format
-
-```csv
-cell,value
-A1,42
-B2,hello
-C3:str,07401
-```
-
-- First row is skipped if the first field does not parse as a valid cell
- reference (e.g., a header like `cell,value` or `ref,val`).
-- Each row is a `cell,value` pair. Standard RFC 4180 CSV quoting applies —
- values containing commas must be quoted: `A1,"hello, world"`.
-- Type tags work in the cell column: `C3:str,07401`.
-- Reads from a file path or `-` for stdin.
-- Can be combined with positional args. **Duplicate resolution:** last-write-wins,
- with positional args applied after CSV. If the CSV sets `A1=42` and a
- positional arg sets `A1=99`, the cell gets `99`.
-
-## Cell Address Parsing
-
-Supports standard Excel A1 notation:
-
-- Column: `A` through `XFD` (1 to 16384).
-- Row: `1` through `1048576`.
-- Examples: `A1`, `Z99`, `AA1`, `XFD1048576`.
-- Case-insensitive: `a1` = `A1`.
-
-Passed to umya-spreadsheet using its string-based cell reference API
-(e.g., `worksheet.get_cell_mut("A1")`).
-
-## Sheet Selection
-
-- `--sheet "Revenue"` selects by name.
-- `--sheet 0` selects by 0-based index.
-- If the argument matches a sheet name exactly, the name takes precedence
- over interpreting it as an index (same behavior as xlcat).
-- Default: first sheet in the workbook.
-- If sheet not found: error listing available sheets.
-
-## Output Behavior
-
-- **Default:** modifies file in-place.
-- **`--output path.xlsx`:** writes to a new file, original untouched.
-- Errors are reported before any write occurs when possible.
-
-## Stderr Confirmation
-
-On success, prints to stderr:
-```
-xlset: updated 3 cells in Revenue (report.xlsx)
-```
-
-No output to stdout. This keeps xlset pipe-friendly.
-
-## Exit Codes
-
-| Code | Meaning |
-|------|---------|
-| 0 | Success |
-| 1 | Runtime error (file not found, corrupt, sheet missing, write failure) |
-| 2 | Invalid arguments (bad cell reference, bad type tag, bad CSV) |
-
-All error messages go to stderr.
-
-## Technology
-
-- **Language:** Rust
-- **Excel read/write:** `umya-spreadsheet` (round-trip editing, preserves formatting)
-- **CLI parsing:** `clap` (derive API, shared with xlcat)
-- **Cell parsing / type inference:** shared library code with xlcat
-- **No runtime dependencies** — single compiled binary
-
-## Project Structure
-
-Same repo, two binaries sharing a library:
-
-```
-Cargo.toml # [[bin]] entries for xlcat and xlset
-src/
-├── lib.rs # crate library root, re-exports modules
-├── bin/
-│ ├── xlcat.rs # xlcat entry point (moved from main.rs)
-│ └── xlset.rs # xlset entry point
-├── cell.rs # A1 notation parser, value type inference
-├── writer.rs # umya-spreadsheet write logic
-├── metadata.rs # existing (used by xlcat)
-├── reader.rs # existing (used by xlcat)
-└── formatter.rs # existing (used by xlcat)
-```
-
-`umya-spreadsheet` is added as a dependency. Existing calamine/polars deps
-remain for xlcat. Shared code in `lib.rs` and `cell.rs` must not import
-crate-specific types (polars, umya-spreadsheet) unconditionally — only
-the binary entry points and their dedicated modules should pull those in.
-With LTO enabled, the linker strips unused code per binary.
-
-## Error Handling
-
-- **File does not exist:** error. xlset operates on existing files only; it
- does not create new workbooks.
-- **File is `.xls` (not `.xlsx`):** "xlset only supports .xlsx files."
-- **File not found:** clear error with path.
-- **File not writable:** "Cannot write to: <path>" with OS error.
-- **Invalid cell reference:** "Invalid cell reference: <ref>" with hint
- (e.g., "Expected format: A1, B2, AA100").
-- **Invalid type tag:** "Unknown type tag: <tag>. Valid tags: str, num, bool, date".
-- **Sheet not found:** list available sheets in error message.
-- **CSV parse error:** "Error on line N: <detail>".
-- **No assignments:** "No cell assignments provided. Use positional args or --from."
-
-## Future Possibilities (Not in Scope)
-
-- Formula setting (`A1==SUM(B1:B10)`)
-- Range fill (`A1:A10=0`)
-- Cell deletion / clearing
-- Row/column insertion
-- Conditional updates (`--if-empty`)
diff --git a/skills/xlcat/SKILL.md b/skills/xlcat/SKILL.md
@@ -0,0 +1,93 @@
+---
+name: xlcat
+description: View and analyze Excel (.xls/.xlsx) files using xlcat. Use when the user asks to open, view, inspect, read, or analyze an Excel spreadsheet, or when you encounter an .xls or .xlsx file that needs to be examined.
+---
+
+# xlcat — Excel File Viewer
+
+View and analyze Excel files at the command line. Outputs structured, LLM-friendly markdown.
+
+## Quick Reference
+
+```bash
+# Overview: metadata + schema + first/last 25 rows
+xlcat file.xlsx
+
+# Column names and types only
+xlcat file.xlsx --schema
+
+# Summary statistics per column
+xlcat file.xlsx --describe
+
+# View a specific sheet (name or 0-based index)
+xlcat file.xlsx --sheet Revenue
+xlcat file.xlsx --sheet 0
+
+# First N rows
+xlcat file.xlsx --head 10
+
+# Last N rows
+xlcat file.xlsx --tail 10
+
+# First N + last M rows
+xlcat file.xlsx --head 10 --tail 5
+
+# All rows (overrides size limit)
+xlcat file.xlsx --all
+
+# Raw CSV output for piping
+xlcat file.xlsx --csv
+
+# Override large-file threshold (default 1MB)
+xlcat file.xlsx --max-size 5M
+```
+
+## Default Behavior
+
+- **Single sheet, <=50 rows:** shows all data
+- **Single sheet, >50 rows:** shows first 25 + last 25 rows
+- **Multiple sheets:** lists all sheets with schemas, no data (use `--sheet` to pick one)
+- **Large file (>1MB):** shows schema + first 25 rows only
+
+## Flags
+
+| Flag | Purpose |
+|-------------------------|------------------------------------------------------------------|
+| `--schema` | Column names and types only |
+| `--describe` | Summary statistics (count, mean, std, min, max, median, unique) |
+| `--head N` | First N rows |
+| `--tail N` | Last N rows |
+| `--all` | All rows (overrides large-file gate) |
+| `--sheet <name\|index>` | Select sheet by name or 0-based index |
+| `--max-size <size>` | Large-file threshold (default: 1M). Accepts: 500K, 1M, 10M, 1G |
+| `--csv` | Output as CSV instead of markdown |
+
+## Exit Codes
+
+| Code | Meaning |
+|------|------------------------------------------|
+| 0 | Success |
+| 1 | Runtime error (file not found, corrupt) |
+| 2 | Invalid arguments |
+
+## Workflow
+
+1. Start with `xlcat <file>` to get 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
+
+## Modes Are Mutually Exclusive
+
+`--schema`, `--describe`, and data mode (default) cannot be combined. Row selection flags (`--head`, `--tail`, `--all`) only work in data mode.
+
+## How to Use This Skill
+
+When the user wants to examine an Excel file:
+
+1. Run `xlcat <file>` to see the overview (sheets, schema, sample data)
+2. If multi-sheet, ask which sheet interests them or use `--sheet`
+3. For data analysis questions, use `--describe` for statistics
+4. For specific rows, use `--head N` / `--tail N`
+5. If the user needs the data for further processing, use `--csv`
diff --git a/skills/xlset/SKILL.md b/skills/xlset/SKILL.md
@@ -0,0 +1,69 @@
+---
+name: xlset
+description: Modify cells in Excel (.xlsx) files using xlset. Use when the user asks to edit, update, change, or write values to an Excel spreadsheet, or when you need to programmatically update cells in an xlsx file.
+---
+
+# xlset — Excel Cell Writer
+
+Modify cells in existing .xlsx files. Preserves formatting, formulas,
+and all structure it doesn't touch.
+
+## Quick Reference
+
+```bash
+# Set a single cell
+xlset file.xlsx A1=42
+
+# Set multiple cells
+xlset file.xlsx A1=42 B2="hello world" C3=true
+
+# Force type with tag (e.g., preserve leading zero)
+xlset file.xlsx A1:str=07401
+
+# Target a specific sheet
+xlset file.xlsx --sheet Revenue A1=42
+
+# Write to a new file (don't modify original)
+xlset file.xlsx --output new.xlsx A1=42
+
+# Bulk update from CSV
+xlset file.xlsx --from updates.csv
+
+# Bulk from stdin
+echo "A1,42" | xlset file.xlsx --from -
+```
+
+## Type Inference
+
+Values are auto-detected:
+- `42` → integer, `3.14` → float
+- `true`/`false` → boolean
+- `2024-01-15` → date
+- Everything else → string
+
+Override with tags: `:str`, `:num`, `:bool`, `:date`
+
+## CSV Format
+
+```csv
+cell,value
+A1,42
+B2,hello
+C3:str,07401
+```
+
+Standard RFC 4180 quoting for values with commas: `A1,"hello, world"`
+
+## Exit Codes
+
+| Code | Meaning |
+|------|-----------------|
+| 0 | Success |
+| 1 | Runtime error |
+| 2 | Invalid arguments |
+
+## Workflow
+
+1. Use `xlcat file.xlsx` first to see current content
+2. Use `xlset` to modify cells
+3. Use `xlcat` again to verify changes