use crate::error::FaucetError;
use serde_json::{Map, Value};
pub const DEFAULT_SHEET: &str = "Sheet1";
pub fn decode(
bytes: &[u8],
sheet: Option<&str>,
header_row: usize,
) -> Result<Vec<Value>, FaucetError> {
use calamine::{Reader, Xlsx};
let mut wb: Xlsx<_> = calamine::open_workbook_from_rs(std::io::Cursor::new(bytes))
.map_err(|e| FaucetError::Source(format!("xlsx: opening workbook: {e}")))?;
let names = wb.sheet_names().to_vec();
let name = resolve_sheet(&names, sheet)?;
let range = wb
.worksheet_range(&name)
.map_err(|e| FaucetError::Source(format!("xlsx: reading sheet '{name}': {e}")))?;
let total_rows = range.rows().count();
if total_rows == 0 {
return Ok(Vec::new());
}
let headers: Vec<String> = range
.rows()
.nth(header_row)
.ok_or_else(|| {
FaucetError::Source(format!(
"xlsx: header_row {header_row} is beyond the sheet ({total_rows} rows)"
))
})?
.iter()
.map(cell_to_string)
.collect();
let mut out = Vec::new();
for row in range.rows().skip(header_row + 1) {
let mut obj = Map::new();
for (i, cell) in row.iter().enumerate() {
let key = headers
.get(i)
.cloned()
.filter(|k| !k.is_empty())
.unwrap_or_else(|| format!("column_{i}"));
obj.insert(key, cell_to_value(cell));
}
out.push(Value::Object(obj));
}
Ok(out)
}
fn resolve_sheet(names: &[String], sheet: Option<&str>) -> Result<String, FaucetError> {
match sheet {
Some(s) if names.iter().any(|n| n == s) => Ok(s.to_string()),
Some(s) => match s.parse::<usize>() {
Ok(idx) => names.get(idx).cloned().ok_or_else(|| {
FaucetError::Source(format!(
"xlsx: sheet index {idx} out of range ({} sheets)",
names.len()
))
}),
Err(_) => Err(FaucetError::Source(format!(
"xlsx: sheet '{s}' not found (available: {})",
names.join(", ")
))),
},
None => names
.first()
.cloned()
.ok_or_else(|| FaucetError::Source("xlsx: workbook has no worksheets".into())),
}
}
pub fn encode(records: &[Value], sheet: Option<&str>) -> Result<Vec<u8>, FaucetError> {
use rust_xlsxwriter::{Workbook, Worksheet};
let headers = super::header_union(records);
let mut book = Workbook::new();
let mut ws = Worksheet::new();
ws.set_name(sheet.unwrap_or(DEFAULT_SHEET))
.map_err(|e| FaucetError::Sink(format!("xlsx: naming the worksheet: {e}")))?;
let err = |e: rust_xlsxwriter::XlsxError| FaucetError::Sink(format!("xlsx: writing: {e}"));
for (col, h) in headers.iter().enumerate() {
ws.write_string(0, col as u16, h.as_str()).map_err(err)?;
}
for (row, r) in records.iter().enumerate() {
let row = (row + 1) as u32;
for (col, h) in headers.iter().enumerate() {
let col = col as u16;
match r.get(h) {
None | Some(Value::Null) => {}
Some(Value::Bool(b)) => {
ws.write_boolean(row, col, *b).map_err(err)?;
}
Some(Value::Number(n)) => match exact_f64(n) {
Some(f) => {
ws.write_number(row, col, f).map_err(err)?;
}
None => {
ws.write_string(row, col, n.to_string().as_str())
.map_err(err)?;
}
},
Some(other) => {
ws.write_string(row, col, super::cell_text(other).as_str())
.map_err(err)?;
}
}
}
}
book.push_worksheet(ws);
book.save_to_buffer()
.map_err(|e| FaucetError::Sink(format!("xlsx: finishing the workbook: {e}")))
}
fn exact_f64(n: &serde_json::Number) -> Option<f64> {
const MAX_EXACT: i64 = 9_007_199_254_740_992;
if let Some(i) = n.as_i64() {
return (-MAX_EXACT..=MAX_EXACT).contains(&i).then_some(i as f64);
}
if let Some(u) = n.as_u64() {
return (u <= MAX_EXACT as u64).then_some(u as f64);
}
n.as_f64()
}
fn float_to_value(f: f64) -> Value {
if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
return Value::from(f as i64);
}
serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null)
}
fn cell_to_string(cell: &calamine::Data) -> String {
use calamine::Data;
match cell {
Data::String(s) => s.clone(),
Data::Empty => String::new(),
other => other.to_string(),
}
}
fn cell_to_value(cell: &calamine::Data) -> Value {
use calamine::Data;
match cell {
Data::Empty => Value::Null,
Data::String(s) => Value::String(s.clone()),
Data::Bool(b) => Value::Bool(*b),
Data::Int(i) => Value::from(*i),
Data::Float(f) => float_to_value(*f),
Data::DateTime(dt) => Value::String(dt.to_string()),
Data::DateTimeIso(s) | Data::DurationIso(s) => Value::String(s.clone()),
Data::Error(e) => Value::String(format!("{e:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_workbook_round_trips_with_its_types() {
let recs = vec![
json!({"id": 1, "name": "ada", "ok": true}),
json!({"id": 2, "name": "grace", "ok": false}),
];
let bytes = encode(&recs, None).expect("encode");
let back = decode(&bytes, None, 0).expect("decode");
assert_eq!(back, recs, "numbers and booleans survive as themselves");
}
#[test]
fn columns_are_the_union_and_a_missing_cell_is_null() {
let bytes = encode(&[json!({"a": 1}), json!({"b": 2})], None).expect("encode");
let back = decode(&bytes, None, 0).expect("decode");
assert_eq!(
back,
vec![json!({"a": 1, "b": null}), json!({"a": null, "b": 2})]
);
}
#[test]
fn a_named_sheet_is_written_and_selected_back() {
let bytes = encode(&[json!({"a": 1})], Some("Data")).expect("encode");
assert!(decode(&bytes, Some("Data"), 0).is_ok());
assert!(decode(&bytes, Some("0"), 0).is_ok());
let err = decode(&bytes, Some("Nope"), 0).expect_err("missing sheet");
assert!(err.to_string().contains("available: Data"), "{err}");
}
#[test]
fn an_empty_workbook_reads_back_as_zero_records_not_an_error() {
let bytes = encode(&[], None).expect("encode");
assert!(decode(&bytes, None, 0).expect("decode").is_empty());
}
#[test]
fn a_header_row_beyond_the_sheet_is_an_error_naming_the_row() {
let bytes = encode(&[json!({"a": 1})], None).expect("encode");
let err = decode(&bytes, None, 99).expect_err("out of range");
assert!(err.to_string().contains("header_row 99"), "{err}");
}
#[test]
fn nested_structure_is_kept_as_json_text_rather_than_dropped() {
let bytes = encode(&[json!({"a": {"k": 1}})], None).expect("encode");
let back = decode(&bytes, None, 0).expect("decode");
assert_eq!(back, vec![json!({"a": r#"{"k":1}"#})]);
}
#[test]
fn a_sheet_index_out_of_range_names_the_count() {
let bytes = encode(&[json!({"a": 1})], None).expect("encode");
let err = decode(&bytes, Some("7"), 0).expect_err("index 7");
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test]
fn a_non_string_header_cell_is_stringified_rather_than_dropped() {
let bytes = encode(&[json!({"2024": "q1", "flag": true})], None).expect("encode");
let back = decode(&bytes, None, 0).expect("decode");
assert_eq!(back, vec![json!({"2024": "q1", "flag": true})]);
}
#[test]
fn a_blank_cell_reads_back_as_null() {
let bytes = encode(&[json!({"a": 1, "b": null})], None).expect("encode");
let back = decode(&bytes, None, 0).expect("decode");
assert_eq!(back, vec![json!({"a": 1, "b": null})]);
}
#[test]
fn not_a_workbook_is_an_error_not_a_panic() {
let err = decode(b"id,name\n1,ada\n", None, 0).expect_err("csv is not xlsx");
assert!(err.to_string().contains("opening workbook"), "{err}");
}
#[test]
fn an_integral_double_reads_back_as_an_integer() {
assert_eq!(float_to_value(1.0), json!(1));
assert_eq!(float_to_value(-3.0), json!(-3));
assert_eq!(float_to_value(1.5), json!(1.5));
assert_eq!(float_to_value(f64::NAN), Value::Null);
}
#[test]
fn a_sheet_named_like_a_number_is_matched_by_name_first() {
let bytes = encode(&[json!({"a": 1})], Some("2024")).expect("encode");
assert!(decode(&bytes, Some("2024"), 0).is_ok());
}
#[test]
fn every_cell_variant_decodes_to_its_json_shape() {
use calamine::{CellErrorType, Data, ExcelDateTime, ExcelDateTimeType};
assert_eq!(cell_to_value(&Data::Empty), Value::Null);
assert_eq!(cell_to_value(&Data::String("s".into())), json!("s"));
assert_eq!(cell_to_value(&Data::Bool(true)), json!(true));
assert_eq!(cell_to_value(&Data::Int(-7)), json!(-7));
assert_eq!(cell_to_value(&Data::Float(2.5)), json!(2.5));
assert_eq!(cell_to_value(&Data::Float(3.0)), json!(3));
assert_eq!(
cell_to_value(&Data::DateTimeIso("2026-01-02T03:04:05".into())),
json!("2026-01-02T03:04:05")
);
assert_eq!(
cell_to_value(&Data::DurationIso("PT1H".into())),
json!("PT1H")
);
assert_eq!(
cell_to_value(&Data::Error(CellErrorType::Div0)),
json!("Div0")
);
let dt = Data::DateTime(ExcelDateTime::new(
45000.5,
ExcelDateTimeType::DateTime,
false,
));
assert!(matches!(cell_to_value(&dt), Value::String(s) if !s.is_empty()));
}
#[test]
fn header_cells_stringify_without_leaking_the_debug_form() {
use calamine::Data;
assert_eq!(cell_to_string(&Data::String("name".into())), "name");
assert_eq!(cell_to_string(&Data::Empty), "");
assert_eq!(cell_to_string(&Data::Int(3)), "3");
assert_eq!(cell_to_string(&Data::Bool(true)), "true");
}
#[test]
fn a_non_finite_double_becomes_null_rather_than_panicking() {
assert_eq!(float_to_value(f64::NAN), Value::Null);
assert_eq!(float_to_value(f64::INFINITY), Value::Null);
assert_eq!(float_to_value(-0.0), json!(0));
}
#[test]
fn only_exactly_representable_numbers_are_written_as_numbers() {
use serde_json::Number;
let n = |v: &str| -> Number { serde_json::from_str(v).expect("number") };
assert_eq!(exact_f64(&n("0")), Some(0.0));
assert_eq!(exact_f64(&n("-1")), Some(-1.0));
assert_eq!(exact_f64(&n("9007199254740992")), Some(9007199254740992.0));
assert_eq!(
exact_f64(&n("-9007199254740992")),
Some(-9007199254740992.0)
);
assert_eq!(exact_f64(&n("9007199254740993")), None);
assert_eq!(exact_f64(&n("-9007199254740993")), None);
assert_eq!(exact_f64(&n("9223372036854775807")), None);
assert_eq!(exact_f64(&n("18446744073709551615")), None);
assert_eq!(exact_f64(&n("1.5")), Some(1.5));
assert_eq!(exact_f64(&n("1e300")), Some(1e300));
}
#[test]
fn an_integer_past_2_53_keeps_its_digits_instead_of_rounding() {
let recs = vec![json!({ "id": 9007199254740993i64, "small": 42 })];
let bytes = encode(&recs, None).expect("encode");
let back = decode(&bytes, None, 0).expect("decode");
assert_eq!(back.len(), 1);
assert_eq!(
back[0]["id"],
Value::String("9007199254740993".into()),
"the exact digits must survive, as text"
);
assert_eq!(back[0]["small"], json!(42));
}
}