xl-cli-tools

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

writer.rs (6461B)


      1 use std::path::Path;
      2 
      3 use anyhow::{bail, Context, Result};
      4 
      5 use crate::cell::{CellAssignment, CellValue};
      6 
      7 /// Write cell assignments to an Excel workbook, preserving existing content and formatting.
      8 ///
      9 /// Opens the workbook at `input_path`, resolves the target sheet, applies all assignments,
     10 /// and saves the result to `output_path`.
     11 ///
     12 /// Returns `(count_of_cells_updated, resolved_sheet_name)`.
     13 pub fn write_cells(
     14     input_path: &Path,
     15     output_path: &Path,
     16     sheet_selector: &str,
     17     assignments: &[CellAssignment],
     18 ) -> Result<(usize, String)> {
     19     // Validate file extension
     20     let ext = input_path
     21         .extension()
     22         .and_then(|e| e.to_str())
     23         .unwrap_or("");
     24     match ext.to_ascii_lowercase().as_str() {
     25         "xlsx" | "xlsm" => {}
     26         "xls" => bail!(
     27             "legacy .xls format is not supported — please convert to .xlsx first"
     28         ),
     29         other => bail!("unsupported file extension '.{}' — expected .xlsx or .xlsm", other),
     30     }
     31 
     32     // Open workbook
     33     let mut book = umya_spreadsheet::reader::xlsx::read(input_path)
     34         .with_context(|| format!("failed to open workbook '{}'", input_path.display()))?;
     35 
     36     // Resolve sheet
     37     let sheet_count = book.get_sheet_count();
     38     let sheet_index = resolve_sheet_index(&book, sheet_selector, sheet_count)?;
     39 
     40     let sheet = book
     41         .get_sheet_mut(&sheet_index)
     42         .with_context(|| format!("failed to access sheet at index {}", sheet_index))?;
     43 
     44     let sheet_name = sheet.get_name().to_string();
     45 
     46     // Apply assignments
     47     for assignment in assignments {
     48         apply_assignment(sheet, assignment);
     49     }
     50 
     51     // Save
     52     umya_spreadsheet::writer::xlsx::write(&book, output_path)
     53         .with_context(|| format!("failed to write workbook to '{}'", output_path.display()))?;
     54 
     55     Ok((assignments.len(), sheet_name))
     56 }
     57 
     58 /// Resolve a sheet selector string to a 0-based sheet index.
     59 ///
     60 /// - Empty string → first sheet (index 0)
     61 /// - Try matching by name first
     62 /// - Then try parsing as a 0-based numeric index
     63 /// - On failure, list available sheet names in the error
     64 fn resolve_sheet_index(
     65     book: &umya_spreadsheet::Spreadsheet,
     66     selector: &str,
     67     sheet_count: usize,
     68 ) -> Result<usize> {
     69     if selector.is_empty() {
     70         return Ok(0);
     71     }
     72 
     73     // Try name match
     74     let sheets = book.get_sheet_collection_no_check();
     75     for (i, ws) in sheets.iter().enumerate() {
     76         if ws.get_name() == selector {
     77             return Ok(i);
     78         }
     79     }
     80 
     81     // Try 0-based index
     82     if let Ok(idx) = selector.parse::<usize>() {
     83         if idx < sheet_count {
     84             return Ok(idx);
     85         }
     86     }
     87 
     88     // Build error with available names
     89     let names: Vec<&str> = sheets.iter().map(|ws| ws.get_name()).collect();
     90     bail!(
     91         "sheet '{}' not found — available sheets: [{}]",
     92         selector,
     93         names.join(", ")
     94     );
     95 }
     96 
     97 /// Apply a single cell assignment to a worksheet.
     98 fn apply_assignment(
     99     sheet: &mut umya_spreadsheet::Worksheet,
    100     assignment: &CellAssignment,
    101 ) {
    102     let cell = sheet.get_cell_mut(assignment.cell.label.as_str());
    103 
    104     match &assignment.value {
    105         CellValue::String(s) => {
    106             cell.set_value_string(s);
    107         }
    108         CellValue::Integer(i) => {
    109             cell.set_value_number(*i as f64);
    110         }
    111         CellValue::Float(f) => {
    112             cell.set_value_number(*f);
    113         }
    114         CellValue::Bool(b) => {
    115             cell.set_value_bool(*b);
    116         }
    117         CellValue::Date { year, month, day } => {
    118             let serial = date_to_serial(*year, *month, *day);
    119             cell.set_value_number(serial);
    120             cell.get_style_mut()
    121                 .get_number_format_mut()
    122                 .set_format_code("yyyy-mm-dd");
    123         }
    124         CellValue::Empty => {
    125             cell.set_value_string("");
    126         }
    127     }
    128 }
    129 
    130 /// Convert a (year, month, day) date to an Excel serial date number.
    131 ///
    132 /// Excel serial dates count days since January 0, 1900 (i.e., Jan 1, 1900 = 1).
    133 /// This function accounts for the Lotus 1-2-3 bug: Excel erroneously treats 1900
    134 /// as a leap year, so dates after Feb 28, 1900 are incremented by 1 to match
    135 /// Excel's numbering.
    136 fn date_to_serial(year: i32, month: u32, day: u32) -> f64 {
    137     // Days in each month (non-leap)
    138     let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    139 
    140     let mut total_days: i64 = 0;
    141 
    142     // Count full years from 1900 to year-1
    143     for y in 1900..year {
    144         total_days += if is_leap_year(y) { 366 } else { 365 };
    145     }
    146 
    147     // Count full months in the target year
    148     for m in 1..month {
    149         total_days += days_in_month[m as usize] as i64;
    150         if m == 2 && is_leap_year(year) {
    151             total_days += 1;
    152         }
    153     }
    154 
    155     // Add days
    156     total_days += day as i64;
    157 
    158     // Excel serial: Jan 1, 1900 = 1 (not 0)
    159     // Lotus 1-2-3 bug: Excel thinks Feb 29, 1900 exists.
    160     // Dates on or after Mar 1, 1900 (serial >= 61) need +1 to compensate.
    161     // Feb 29, 1900 itself would be serial 60 in Excel's world (the phantom day).
    162     if total_days >= 60 {
    163         total_days += 1;
    164     }
    165 
    166     total_days as f64
    167 }
    168 
    169 /// Check if a year is a real leap year.
    170 fn is_leap_year(year: i32) -> bool {
    171     (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
    172 }
    173 
    174 #[cfg(test)]
    175 mod tests {
    176     use super::*;
    177 
    178     #[test]
    179     fn test_date_to_serial_known_dates() {
    180         // Jan 1, 1900 = serial 1
    181         assert_eq!(date_to_serial(1900, 1, 1), 1.0);
    182         // Jan 1, 2024 = serial 45292
    183         assert_eq!(date_to_serial(2024, 1, 1), 45292.0);
    184     }
    185 
    186     #[test]
    187     fn test_date_to_serial_epoch_boundary() {
    188         // Feb 28, 1900 = serial 59 (last real date before the phantom leap day)
    189         assert_eq!(date_to_serial(1900, 2, 28), 59.0);
    190         // Mar 1, 1900 = serial 61 (after the phantom Feb 29)
    191         assert_eq!(date_to_serial(1900, 3, 1), 61.0);
    192     }
    193 
    194     #[test]
    195     fn test_date_to_serial_common_dates() {
    196         // Dec 31, 1899 is not representable (before epoch) — but Jan 2, 1900 = 2
    197         assert_eq!(date_to_serial(1900, 1, 2), 2.0);
    198         // Excel: 2000-01-01 = 36526
    199         assert_eq!(date_to_serial(2000, 1, 1), 36526.0);
    200     }
    201 
    202     #[test]
    203     fn test_is_leap_year() {
    204         assert!(!is_leap_year(1900)); // not a real leap year
    205         assert!(is_leap_year(2000));  // divisible by 400
    206         assert!(is_leap_year(2024));  // divisible by 4, not by 100
    207         assert!(!is_leap_year(1999)); // odd year
    208     }
    209 }