Skip to main content

browser_automation_cli/
sheet_local.rs

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