xl-cli-tools

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

xlset.rs (7067B)


      1 use xlcat::cell::{parse_assignment, parse_cell_ref, CellAssignment};
      2 use xlcat::writer::write_cells;
      3 
      4 use anyhow::Result;
      5 use clap::Parser;
      6 use std::io::{self, BufRead};
      7 use std::path::PathBuf;
      8 use std::process;
      9 
     10 // ---------------------------------------------------------------------------
     11 // CLI definition
     12 // ---------------------------------------------------------------------------
     13 
     14 #[derive(Parser, Debug)]
     15 #[command(name = "xlset", about = "Write values into Excel cells")]
     16 struct Cli {
     17     /// Path to .xlsx file
     18     file: PathBuf,
     19 
     20     /// Cell assignments, e.g. A1=42 B2=hello
     21     #[arg(trailing_var_arg = true, num_args = 0..)]
     22     assignments: Vec<String>,
     23 
     24     /// Target sheet by name or 0-based index (default: first sheet)
     25     #[arg(long, default_value = "")]
     26     sheet: String,
     27 
     28     /// Write to a different file instead of updating in-place
     29     #[arg(long)]
     30     output: Option<PathBuf>,
     31 
     32     /// Read assignments from a CSV file, or `-` for stdin
     33     #[arg(long)]
     34     from: Option<String>,
     35 }
     36 
     37 // ---------------------------------------------------------------------------
     38 // ArgError — user-facing argument errors (exit code 2)
     39 // ---------------------------------------------------------------------------
     40 
     41 #[derive(Debug)]
     42 struct ArgError(String);
     43 
     44 impl std::fmt::Display for ArgError {
     45     fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
     46         write!(f, "{}", self.0)
     47     }
     48 }
     49 
     50 impl std::error::Error for ArgError {}
     51 
     52 // ---------------------------------------------------------------------------
     53 // CSV parsing
     54 // ---------------------------------------------------------------------------
     55 
     56 /// Read cell assignments from a CSV source (file path or `-` for stdin).
     57 ///
     58 /// Format: `cell,value` per line.
     59 /// - First row is skipped if its first field is not a valid cell reference (header detection).
     60 /// - Values may use RFC 4180 quoting: `A1,"hello, world"`.
     61 fn read_csv_assignments(source: &str) -> Result<Vec<CellAssignment>> {
     62     let lines: Vec<String> = if source == "-" {
     63         let stdin = io::stdin();
     64         stdin.lock().lines().collect::<std::io::Result<_>>()?
     65     } else {
     66         let file = std::fs::File::open(source)
     67             .map_err(|e| anyhow::anyhow!("cannot open --from file '{}': {}", source, e))?;
     68         io::BufReader::new(file)
     69             .lines()
     70             .collect::<std::io::Result<_>>()?
     71     };
     72 
     73     let mut assignments = Vec::new();
     74     let mut skip_first = false;
     75     let mut first_line = true;
     76 
     77     for (line_idx, line) in lines.iter().enumerate() {
     78         let line_num = line_idx + 1;
     79         let trimmed = line.trim();
     80         if trimmed.is_empty() {
     81             first_line = false;
     82             continue;
     83         }
     84 
     85         // Split on first comma not inside quotes
     86         let (cell_str, value_str) = split_csv_line(trimmed).ok_or_else(|| {
     87             ArgError(format!(
     88                 "--from line {}: expected 'cell,value' but got '{}'",
     89                 line_num, trimmed
     90             ))
     91         })?;
     92 
     93         let cell_str = cell_str.trim();
     94         let value_str = unquote_csv(value_str.trim());
     95 
     96         // Header detection: if the first row's cell field is not a valid cell ref, skip it
     97         if first_line {
     98             first_line = false;
     99             if parse_cell_ref(cell_str).is_err() {
    100                 skip_first = true;
    101                 continue;
    102             }
    103         }
    104         let _ = skip_first; // already consumed above
    105 
    106         let _cell = parse_cell_ref(cell_str).map_err(|e| {
    107             ArgError(format!("--from line {}: invalid cell reference: {}", line_num, e))
    108         })?;
    109 
    110         // Build a synthetic assignment string and parse value via infer logic.
    111         // Since we already have cell and raw value separately, construct CellAssignment directly.
    112         let assignment_str = format!("{}={}", cell_str, value_str);
    113         let assignment = parse_assignment(&assignment_str).map_err(|e| {
    114             ArgError(format!("--from line {}: {}", line_num, e))
    115         })?;
    116 
    117         assignments.push(assignment);
    118     }
    119 
    120     Ok(assignments)
    121 }
    122 
    123 /// Split a CSV line on the first comma that is not inside double quotes.
    124 /// Returns `(left, right)` or `None` if no comma is found outside quotes.
    125 fn split_csv_line(line: &str) -> Option<(&str, &str)> {
    126     let mut in_quotes = false;
    127     let mut escaped = false;
    128 
    129     for (i, ch) in line.char_indices() {
    130         if escaped {
    131             escaped = false;
    132             continue;
    133         }
    134         match ch {
    135             '"' => in_quotes = !in_quotes,
    136             ',' if !in_quotes => {
    137                 return Some((&line[..i], &line[i + 1..]));
    138             }
    139             _ => {}
    140         }
    141     }
    142     None
    143 }
    144 
    145 /// Remove RFC 4180 quoting from a CSV field value.
    146 /// `"hello, world"` → `hello, world`
    147 /// `"say ""hi"""` → `say "hi"`
    148 fn unquote_csv(s: &str) -> String {
    149     if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
    150         let inner = &s[1..s.len() - 1];
    151         inner.replace("\"\"", "\"")
    152     } else {
    153         s.to_string()
    154     }
    155 }
    156 
    157 // ---------------------------------------------------------------------------
    158 // Orchestration
    159 // ---------------------------------------------------------------------------
    160 
    161 fn run(cli: &Cli) -> Result<()> {
    162     // 1. Validate input file exists
    163     if !cli.file.exists() {
    164         return Err(anyhow::anyhow!(
    165             "file not found: '{}'",
    166             cli.file.display()
    167         ));
    168     }
    169 
    170     // 2. Collect assignments from --from CSV if provided
    171     let mut assignments: Vec<CellAssignment> = Vec::new();
    172 
    173     if let Some(ref source) = cli.from {
    174         let csv_assignments = read_csv_assignments(source)?;
    175         assignments.extend(csv_assignments);
    176     }
    177 
    178     // 3. Collect assignments from positional args
    179     for arg in &cli.assignments {
    180         let a = parse_assignment(arg).map_err(|e| ArgError(e))?;
    181         assignments.push(a);
    182     }
    183 
    184     // 4. Require at least one assignment
    185     if assignments.is_empty() {
    186         return Err(ArgError(
    187             "no assignments provided — use A1=value syntax or --from <file>".into(),
    188         )
    189         .into());
    190     }
    191 
    192     // 5. Determine output path
    193     let output_path = cli.output.clone().unwrap_or_else(|| cli.file.clone());
    194 
    195     // 6. Call writer
    196     let (count, sheet_name) = write_cells(&cli.file, &output_path, &cli.sheet, &assignments)?;
    197 
    198     // 7. Print confirmation to stderr
    199     let file_name = output_path
    200         .file_name()
    201         .map(|s| s.to_string_lossy().to_string())
    202         .unwrap_or_else(|| output_path.display().to_string());
    203 
    204     eprintln!("xlset: updated {} cells in {} ({})", count, sheet_name, file_name);
    205 
    206     Ok(())
    207 }
    208 
    209 // ---------------------------------------------------------------------------
    210 // main()
    211 // ---------------------------------------------------------------------------
    212 
    213 fn main() {
    214     let cli = Cli::parse();
    215     if let Err(err) = run(&cli) {
    216         if err.downcast_ref::<ArgError>().is_some() {
    217             eprintln!("xlset: {err}");
    218             process::exit(2);
    219         }
    220         eprintln!("xlset: {err}");
    221         process::exit(1);
    222     }
    223 }