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 }