xl-cli-tools

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

commit 9f852d4250367cf5f7c2dc7094098d504a6dc3ac
parent ff4463493700d3118cef06f9abe10956c4323881
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Fri, 13 Mar 2026 19:09:30 -0500

feat: add xlset binary — Excel cell writer CLI

Implements the full xlset CLI: clap-derived arg parsing, CSV ingestion
via --from (file or stdin with RFC 4180 quoting and header detection),
in-place and --output write modes, and ArgError exit-code-2 handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Msrc/bin/xlset.rs | 223++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 221 insertions(+), 2 deletions(-)

diff --git a/src/bin/xlset.rs b/src/bin/xlset.rs @@ -1,4 +1,223 @@ +use xlcat::cell::{parse_assignment, parse_cell_ref, CellAssignment}; +use xlcat::writer::write_cells; + +use anyhow::Result; +use clap::Parser; +use std::io::{self, BufRead}; +use std::path::PathBuf; +use std::process; + +// --------------------------------------------------------------------------- +// CLI definition +// --------------------------------------------------------------------------- + +#[derive(Parser, Debug)] +#[command(name = "xlset", about = "Write values into Excel cells")] +struct Cli { + /// Path to .xlsx file + file: PathBuf, + + /// Cell assignments, e.g. A1=42 B2=hello + #[arg(trailing_var_arg = true, num_args = 0..)] + 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 updating in-place + #[arg(long)] + output: Option<PathBuf>, + + /// Read assignments from a CSV file, or `-` for stdin + #[arg(long)] + from: Option<String>, +} + +// --------------------------------------------------------------------------- +// ArgError — user-facing argument errors (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 {} + +// --------------------------------------------------------------------------- +// CSV parsing +// --------------------------------------------------------------------------- + +/// Read cell assignments from a CSV source (file path or `-` for stdin). +/// +/// Format: `cell,value` per line. +/// - First row is skipped if its first field is not a valid cell reference (header detection). +/// - Values may use RFC 4180 quoting: `A1,"hello, world"`. +fn read_csv_assignments(source: &str) -> Result<Vec<CellAssignment>> { + let lines: Vec<String> = if source == "-" { + let stdin = io::stdin(); + stdin.lock().lines().collect::<std::io::Result<_>>()? + } else { + let file = std::fs::File::open(source) + .map_err(|e| anyhow::anyhow!("cannot open --from file '{}': {}", source, e))?; + io::BufReader::new(file) + .lines() + .collect::<std::io::Result<_>>()? + }; + + let mut assignments = Vec::new(); + let mut skip_first = false; + let mut first_line = true; + + for (line_idx, line) in lines.iter().enumerate() { + let line_num = line_idx + 1; + let trimmed = line.trim(); + if trimmed.is_empty() { + first_line = false; + continue; + } + + // Split on first comma not inside quotes + let (cell_str, value_str) = split_csv_line(trimmed).ok_or_else(|| { + ArgError(format!( + "--from line {}: expected 'cell,value' but got '{}'", + line_num, trimmed + )) + })?; + + let cell_str = cell_str.trim(); + let value_str = unquote_csv(value_str.trim()); + + // Header detection: if the first row's cell field is not a valid cell ref, skip it + if first_line { + first_line = false; + if parse_cell_ref(cell_str).is_err() { + skip_first = true; + continue; + } + } + let _ = skip_first; // already consumed above + + let _cell = parse_cell_ref(cell_str).map_err(|e| { + ArgError(format!("--from line {}: invalid cell reference: {}", line_num, e)) + })?; + + // Build a synthetic assignment string and parse value via infer logic. + // Since we already have cell and raw value separately, construct CellAssignment directly. + let assignment_str = format!("{}={}", cell_str, value_str); + let assignment = parse_assignment(&assignment_str).map_err(|e| { + ArgError(format!("--from line {}: {}", line_num, e)) + })?; + + assignments.push(assignment); + } + + Ok(assignments) +} + +/// Split a CSV line on the first comma that is not inside double quotes. +/// Returns `(left, right)` or `None` if no comma is found outside quotes. +fn split_csv_line(line: &str) -> Option<(&str, &str)> { + let mut in_quotes = false; + let mut escaped = false; + + for (i, ch) in line.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + return Some((&line[..i], &line[i + 1..])); + } + _ => {} + } + } + None +} + +/// Remove RFC 4180 quoting from a CSV field value. +/// `"hello, world"` → `hello, world` +/// `"say ""hi"""` → `say "hi"` +fn unquote_csv(s: &str) -> String { + if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { + let inner = &s[1..s.len() - 1]; + inner.replace("\"\"", "\"") + } else { + s.to_string() + } +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +fn run(cli: &Cli) -> Result<()> { + // 1. Validate input file exists + if !cli.file.exists() { + return Err(anyhow::anyhow!( + "file not found: '{}'", + cli.file.display() + )); + } + + // 2. Collect assignments from --from CSV if provided + let mut assignments: Vec<CellAssignment> = Vec::new(); + + if let Some(ref source) = cli.from { + let csv_assignments = read_csv_assignments(source)?; + assignments.extend(csv_assignments); + } + + // 3. Collect assignments from positional args + for arg in &cli.assignments { + let a = parse_assignment(arg).map_err(|e| ArgError(e))?; + assignments.push(a); + } + + // 4. Require at least one assignment + if assignments.is_empty() { + return Err(ArgError( + "no assignments provided — use A1=value syntax or --from <file>".into(), + ) + .into()); + } + + // 5. Determine output path + let output_path = cli.output.clone().unwrap_or_else(|| cli.file.clone()); + + // 6. Call writer + let (count, sheet_name) = write_cells(&cli.file, &output_path, &cli.sheet, &assignments)?; + + // 7. Print confirmation to stderr + let file_name = output_path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| output_path.display().to_string()); + + eprintln!("xlset: updated {} cells in {} ({})", count, sheet_name, file_name); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// main() +// --------------------------------------------------------------------------- + fn main() { - eprintln!("xlset: not yet implemented"); - std::process::exit(1); + 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); + } }