xl-cli-tools

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

commit ff4463493700d3118cef06f9abe10956c4323881
parent 43feec94bec9cc5128481518b7120a4bda23fde4
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Fri, 13 Mar 2026 19:05:52 -0500

feat: add writer module — umya-spreadsheet cell editing

Implement write_cells() for round-trip Excel editing: open workbook,
resolve sheet by name or index, apply typed cell assignments, save.
Handles String, Integer, Float, Bool, Date (with Excel serial number
conversion including Lotus 1-2-3 leap year bug), and Empty values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/writer.rs | 210++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 209 insertions(+), 1 deletion(-)

diff --git a/src/writer.rs b/src/writer.rs @@ -1 +1,209 @@ -// umya-spreadsheet write logic (implemented later) +use std::path::Path; + +use anyhow::{bail, Context, Result}; + +use crate::cell::{CellAssignment, CellValue}; + +/// Write cell assignments to an Excel workbook, preserving existing content and formatting. +/// +/// Opens the workbook at `input_path`, resolves the target sheet, applies all assignments, +/// and saves the result to `output_path`. +/// +/// Returns `(count_of_cells_updated, resolved_sheet_name)`. +pub fn write_cells( + input_path: &Path, + output_path: &Path, + sheet_selector: &str, + assignments: &[CellAssignment], +) -> Result<(usize, String)> { + // Validate file extension + let ext = input_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext.to_ascii_lowercase().as_str() { + "xlsx" | "xlsm" => {} + "xls" => bail!( + "legacy .xls format is not supported — please convert to .xlsx first" + ), + other => bail!("unsupported file extension '.{}' — expected .xlsx or .xlsm", other), + } + + // Open workbook + let mut book = umya_spreadsheet::reader::xlsx::read(input_path) + .with_context(|| format!("failed to open workbook '{}'", input_path.display()))?; + + // Resolve sheet + let sheet_count = book.get_sheet_count(); + let sheet_index = resolve_sheet_index(&book, sheet_selector, sheet_count)?; + + let sheet = book + .get_sheet_mut(&sheet_index) + .with_context(|| format!("failed to access sheet at index {}", sheet_index))?; + + let sheet_name = sheet.get_name().to_string(); + + // Apply assignments + for assignment in assignments { + apply_assignment(sheet, assignment); + } + + // Save + umya_spreadsheet::writer::xlsx::write(&book, output_path) + .with_context(|| format!("failed to write workbook to '{}'", output_path.display()))?; + + Ok((assignments.len(), sheet_name)) +} + +/// Resolve a sheet selector string to a 0-based sheet index. +/// +/// - Empty string → first sheet (index 0) +/// - Try matching by name first +/// - Then try parsing as a 0-based numeric index +/// - On failure, list available sheet names in the error +fn resolve_sheet_index( + book: &umya_spreadsheet::Spreadsheet, + selector: &str, + sheet_count: usize, +) -> Result<usize> { + if selector.is_empty() { + return Ok(0); + } + + // Try name match + let sheets = book.get_sheet_collection_no_check(); + for (i, ws) in sheets.iter().enumerate() { + if ws.get_name() == selector { + return Ok(i); + } + } + + // Try 0-based index + if let Ok(idx) = selector.parse::<usize>() { + if idx < sheet_count { + return Ok(idx); + } + } + + // Build error with available names + let names: Vec<&str> = sheets.iter().map(|ws| ws.get_name()).collect(); + bail!( + "sheet '{}' not found — available sheets: [{}]", + selector, + names.join(", ") + ); +} + +/// Apply a single cell assignment to a worksheet. +fn apply_assignment( + sheet: &mut umya_spreadsheet::Worksheet, + assignment: &CellAssignment, +) { + let cell = sheet.get_cell_mut(assignment.cell.label.as_str()); + + match &assignment.value { + CellValue::String(s) => { + 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(""); + } + } +} + +/// Convert a (year, month, day) date to an Excel serial date number. +/// +/// Excel serial dates count days since January 0, 1900 (i.e., Jan 1, 1900 = 1). +/// This function accounts for the Lotus 1-2-3 bug: Excel erroneously treats 1900 +/// as a leap year, so dates after Feb 28, 1900 are incremented by 1 to match +/// Excel's numbering. +fn date_to_serial(year: i32, month: u32, day: u32) -> f64 { + // Days in each month (non-leap) + let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + let mut total_days: i64 = 0; + + // Count full years from 1900 to year-1 + for y in 1900..year { + total_days += if is_leap_year(y) { 366 } else { 365 }; + } + + // Count full months in the target year + for m in 1..month { + total_days += days_in_month[m as usize] as i64; + if m == 2 && is_leap_year(year) { + total_days += 1; + } + } + + // Add days + total_days += day as i64; + + // Excel serial: Jan 1, 1900 = 1 (not 0) + // Lotus 1-2-3 bug: Excel thinks Feb 29, 1900 exists. + // Dates on or after Mar 1, 1900 (serial >= 61) need +1 to compensate. + // Feb 29, 1900 itself would be serial 60 in Excel's world (the phantom day). + if total_days >= 60 { + total_days += 1; + } + + total_days as f64 +} + +/// Check if a year is a real leap year. +fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_date_to_serial_known_dates() { + // Jan 1, 1900 = serial 1 + assert_eq!(date_to_serial(1900, 1, 1), 1.0); + // Jan 1, 2024 = serial 45292 + assert_eq!(date_to_serial(2024, 1, 1), 45292.0); + } + + #[test] + fn test_date_to_serial_epoch_boundary() { + // Feb 28, 1900 = serial 59 (last real date before the phantom leap day) + assert_eq!(date_to_serial(1900, 2, 28), 59.0); + // Mar 1, 1900 = serial 61 (after the phantom Feb 29) + assert_eq!(date_to_serial(1900, 3, 1), 61.0); + } + + #[test] + fn test_date_to_serial_common_dates() { + // Dec 31, 1899 is not representable (before epoch) — but Jan 2, 1900 = 2 + assert_eq!(date_to_serial(1900, 1, 2), 2.0); + // Excel: 2000-01-01 = 36526 + assert_eq!(date_to_serial(2000, 1, 1), 36526.0); + } + + #[test] + fn test_is_leap_year() { + assert!(!is_leap_year(1900)); // not a real leap year + assert!(is_leap_year(2000)); // divisible by 400 + assert!(is_leap_year(2024)); // divisible by 4, not by 100 + assert!(!is_leap_year(1999)); // odd year + } +}