Skip to main content

browser_automation_cli/
sheet_local.rs

1//! One-shot XLSX write via `rust_xlsxwriter` (§5Z / GAP-A011).
2//!
3//! Read path remains `calamine` in `scrape_local`/`parse`. This module is write-only.
4
5use std::fs;
6use std::path::Path;
7
8use rust_xlsxwriter::Workbook;
9use serde_json::Value;
10
11use crate::error::{CliError, ErrorKind};
12
13/// Write an XLSX workbook from CSV or JSON array-of-objects input.
14pub fn sheet_write(input: &Path, out: &Path, sheet_name: &str) -> Result<Value, CliError> {
15    if sheet_name.trim().is_empty() {
16        return Err(CliError::new(
17            ErrorKind::Usage,
18            "sheet name must not be empty",
19        ));
20    }
21    let ext = input
22        .extension()
23        .and_then(|e| e.to_str())
24        .unwrap_or("")
25        .to_ascii_lowercase();
26    let rows = match ext.as_str() {
27        "csv" | "tsv" => read_csv_rows(input, if ext == "tsv" { b'\t' } else { b',' })?,
28        "json" => read_json_rows(input)?,
29        other => {
30            return Err(CliError::with_suggestion(
31                ErrorKind::Usage,
32                format!("unsupported sheet-write input extension: {other}"),
33                "Pass a .csv, .tsv, or .json (array of objects) file",
34            ));
35        }
36    };
37    if rows.is_empty() {
38        return Err(CliError::new(ErrorKind::Data, "no rows to write"));
39    }
40
41    let mut workbook = Workbook::new();
42    let worksheet = workbook
43        .add_worksheet()
44        .set_name(sheet_name)
45        .map_err(|e| CliError::new(ErrorKind::Software, format!("worksheet name: {e}")))?;
46
47    for (r, row) in rows.iter().enumerate() {
48        for (c, cell) in row.iter().enumerate() {
49            worksheet
50                .write_string(r as u32, c as u16, cell)
51                .map_err(|e| CliError::new(ErrorKind::Software, format!("write cell: {e}")))?;
52        }
53    }
54
55    // Write to temp then rename for residual-friendly finalize.
56    let parent = out.parent().unwrap_or_else(|| Path::new("."));
57    let tmp = parent.join(format!(
58        ".browser-automation-cli-xlsx-{}.tmp",
59        std::process::id()
60    ));
61    workbook
62        .save(&tmp)
63        .map_err(|e| CliError::new(ErrorKind::Io, format!("xlsx save: {e}")))?;
64    fs::rename(&tmp, out).map_err(|e| {
65        let _ = fs::remove_file(&tmp);
66        CliError::new(
67            ErrorKind::Io,
68            format!("rename xlsx {} → {}: {e}", tmp.display(), out.display()),
69        )
70    })?;
71
72    Ok(serde_json::json!({
73        "ok": true,
74        "path": out.display().to_string(),
75        "rows": rows.len(),
76        "cols": rows.first().map(|r| r.len()).unwrap_or(0),
77        "sheet": sheet_name,
78        "chrome": false,
79    }))
80}
81
82fn read_csv_rows(path: &Path, delim: u8) -> Result<Vec<Vec<String>>, CliError> {
83    let raw = fs::read(path)
84        .map_err(|e| CliError::new(ErrorKind::Io, format!("read {}: {e}", path.display())))?;
85    let mut rdr = csv::ReaderBuilder::new()
86        .delimiter(delim)
87        .flexible(true)
88        .has_headers(false)
89        .from_reader(raw.as_slice());
90    let mut rows = Vec::new();
91    for rec in rdr.records() {
92        let rec = rec.map_err(|e| CliError::new(ErrorKind::Data, format!("csv: {e}")))?;
93        rows.push(rec.iter().map(|s| s.to_string()).collect());
94    }
95    Ok(rows)
96}
97
98fn read_json_rows(path: &Path) -> Result<Vec<Vec<String>>, CliError> {
99    let raw = fs::read_to_string(path)
100        .map_err(|e| CliError::new(ErrorKind::Io, format!("read {}: {e}", path.display())))?;
101    let v: Value = serde_json::from_str(&raw)
102        .map_err(|e| CliError::new(ErrorKind::Data, format!("json: {e}")))?;
103    let arr = v.as_array().ok_or_else(|| {
104        CliError::with_suggestion(
105            ErrorKind::Data,
106            "JSON input must be an array of objects or array of arrays",
107            "Example: [{\"a\":1,\"b\":2},{\"a\":3,\"b\":4}]",
108        )
109    })?;
110    if arr.is_empty() {
111        return Ok(Vec::new());
112    }
113    // Array of arrays.
114    if arr[0].is_array() {
115        let mut rows = Vec::new();
116        for row in arr {
117            let cells = row
118                .as_array()
119                .ok_or_else(|| CliError::new(ErrorKind::Data, "expected array row"))?
120                .iter()
121                .map(json_cell)
122                .collect();
123            rows.push(cells);
124        }
125        return Ok(rows);
126    }
127    // Array of objects: header = sorted keys of first object for stability.
128    let first = arr[0]
129        .as_object()
130        .ok_or_else(|| CliError::new(ErrorKind::Data, "expected object row"))?;
131    let mut keys: Vec<String> = first.keys().cloned().collect();
132    keys.sort();
133    let mut rows = vec![keys.clone()];
134    for obj in arr {
135        let map = obj
136            .as_object()
137            .ok_or_else(|| CliError::new(ErrorKind::Data, "expected object row"))?;
138        let row = keys
139            .iter()
140            .map(|k| map.get(k).map(json_cell).unwrap_or_default())
141            .collect();
142        rows.push(row);
143    }
144    Ok(rows)
145}
146
147fn json_cell(v: &Value) -> String {
148    match v {
149        Value::Null => String::new(),
150        Value::String(s) => s.clone(),
151        Value::Bool(b) => b.to_string(),
152        Value::Number(n) => n.to_string(),
153        other => other.to_string(),
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn writes_csv_to_xlsx() {
163        let dir = tempfile::tempdir().unwrap();
164        let csv_path = dir.path().join("t.csv");
165        fs::write(&csv_path, "a,b\n1,2\n").unwrap();
166        let out = dir.path().join("t.xlsx");
167        let v = sheet_write(&csv_path, &out, "Sheet1").unwrap();
168        assert!(out.exists());
169        assert_eq!(v.get("rows").and_then(|r| r.as_u64()), Some(2));
170        // Magic: ZIP/XLSX starts with PK
171        let bytes = fs::read(&out).unwrap();
172        assert!(bytes.starts_with(b"PK"));
173    }
174}