1use crate::error::FaucetError;
13use serde_json::{Map, Value};
14
15pub const DEFAULT_SHEET: &str = "Sheet1";
17
18pub fn decode(
25 bytes: &[u8],
26 sheet: Option<&str>,
27 header_row: usize,
28) -> Result<Vec<Value>, FaucetError> {
29 use calamine::{Reader, Xlsx};
30 let mut wb: Xlsx<_> = calamine::open_workbook_from_rs(std::io::Cursor::new(bytes))
34 .map_err(|e| FaucetError::Source(format!("xlsx: opening workbook: {e}")))?;
35 let names = wb.sheet_names().to_vec();
36 let name = resolve_sheet(&names, sheet)?;
37 let name_of_sheet = name.clone();
38 let range = wb
39 .worksheet_range(&name)
40 .map_err(|e| FaucetError::Source(format!("xlsx: reading sheet '{name}': {e}")))?;
41 let total_rows = range.rows().count();
45 if total_rows == 0 {
46 return Ok(Vec::new());
47 }
48 let headers: Vec<String> = range
49 .rows()
50 .nth(header_row)
51 .ok_or_else(|| {
52 FaucetError::Source(format!(
53 "xlsx: header_row {header_row} is beyond the sheet ({total_rows} rows)"
54 ))
55 })?
56 .iter()
57 .enumerate()
58 .map(|(i, c)| {
59 let label = cell_to_string(c);
60 if label.is_empty() {
61 format!("column_{i}")
62 } else {
63 label
64 }
65 })
66 .collect();
67 let mut seen = std::collections::HashMap::with_capacity(headers.len());
70 for (i, name) in headers.iter().enumerate() {
71 if let Some(first) = seen.insert(name.as_str(), i) {
72 return Err(FaucetError::Source(format!(
73 "xlsx: sheet '{name_of_sheet}' repeats the header `{name}` at columns {first} \
74 and {i}; rows are keyed by header, so one column would be lost — rename it"
75 )));
76 }
77 }
78 let mut out = Vec::new();
81 for row in range.rows().skip(header_row + 1) {
82 let obj: Map<String, Value> = headers
83 .iter()
84 .cloned()
85 .zip(row.iter().map(cell_to_value))
86 .collect();
87 out.push(Value::Object(obj));
88 }
89 Ok(out)
90}
91
92fn resolve_sheet(names: &[String], sheet: Option<&str>) -> Result<String, FaucetError> {
98 match sheet {
99 Some(s) if names.iter().any(|n| n == s) => Ok(s.to_string()),
100 Some(s) => match s.parse::<usize>() {
101 Ok(idx) => names.get(idx).cloned().ok_or_else(|| {
102 FaucetError::Source(format!(
103 "xlsx: sheet index {idx} out of range ({} sheets)",
104 names.len()
105 ))
106 }),
107 Err(_) => Err(FaucetError::Source(format!(
108 "xlsx: sheet '{s}' not found (available: {})",
109 names.join(", ")
110 ))),
111 },
112 None => names
113 .first()
114 .cloned()
115 .ok_or_else(|| FaucetError::Source("xlsx: workbook has no worksheets".into())),
116 }
117}
118
119pub fn encode(records: &[Value], sheet: Option<&str>) -> Result<Vec<u8>, FaucetError> {
126 use rust_xlsxwriter::{Workbook, Worksheet};
127 let headers = super::header_union(records);
128 let mut book = Workbook::new();
129 let mut ws = Worksheet::new();
130 ws.set_name(sheet.unwrap_or(DEFAULT_SHEET))
131 .map_err(|e| FaucetError::Sink(format!("xlsx: naming the worksheet: {e}")))?;
132
133 let err = |e: rust_xlsxwriter::XlsxError| FaucetError::Sink(format!("xlsx: writing: {e}"));
134 for (col, h) in headers.iter().enumerate() {
135 ws.write_string(0, col as u16, h.as_str()).map_err(err)?;
136 }
137 for (row, r) in records.iter().enumerate() {
138 let row = (row + 1) as u32;
139 for (col, h) in headers.iter().enumerate() {
140 let col = col as u16;
141 match r.get(h) {
142 None | Some(Value::Null) => {}
143 Some(Value::Bool(b)) => {
144 ws.write_boolean(row, col, *b).map_err(err)?;
145 }
146 Some(Value::Number(n)) => match exact_f64(n) {
147 Some(f) => {
148 ws.write_number(row, col, f).map_err(err)?;
149 }
150 None => {
153 ws.write_string(row, col, n.to_string().as_str())
154 .map_err(err)?;
155 }
156 },
157 Some(other) => {
158 ws.write_string(row, col, super::cell_text(other).as_str())
159 .map_err(err)?;
160 }
161 }
162 }
163 }
164 book.push_worksheet(ws);
165 book.save_to_buffer()
166 .map_err(|e| FaucetError::Sink(format!("xlsx: finishing the workbook: {e}")))
167}
168
169fn exact_f64(n: &serde_json::Number) -> Option<f64> {
179 const MAX_EXACT: i64 = 9_007_199_254_740_992;
181 if let Some(i) = n.as_i64() {
182 return (-MAX_EXACT..=MAX_EXACT).contains(&i).then_some(i as f64);
183 }
184 if let Some(u) = n.as_u64() {
185 return (u <= MAX_EXACT as u64).then_some(u as f64);
186 }
187 n.as_f64()
189}
190
191fn float_to_value(f: f64) -> Value {
193 if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
194 return Value::from(f as i64);
195 }
196 serde_json::Number::from_f64(f)
197 .map(Value::Number)
198 .unwrap_or(Value::Null)
199}
200
201fn excel_datetime_text(dt: &calamine::ExcelDateTime) -> String {
206 if dt.is_duration() {
207 return dt
208 .as_duration()
209 .map_or_else(|| dt.as_f64().to_string(), |d| d.to_string());
210 }
211 match dt.as_datetime() {
212 Some(t) if t.time() == chrono::NaiveTime::MIN => t.date().to_string(),
213 Some(t) => t.format("%Y-%m-%dT%H:%M:%S%.f").to_string(),
214 None => dt.as_f64().to_string(),
215 }
216}
217
218fn cell_to_string(cell: &calamine::Data) -> String {
219 use calamine::Data;
220 match cell {
221 Data::String(s) => s.clone(),
222 Data::Empty => String::new(),
223 Data::DateTime(dt) => excel_datetime_text(dt),
224 other => other.to_string(),
225 }
226}
227
228fn cell_to_value(cell: &calamine::Data) -> Value {
229 use calamine::Data;
230 match cell {
231 Data::Empty => Value::Null,
232 Data::String(s) => Value::String(s.clone()),
233 Data::Bool(b) => Value::Bool(*b),
234 Data::Int(i) => Value::from(*i),
235 Data::Float(f) => float_to_value(*f),
240 Data::DateTime(dt) => Value::String(excel_datetime_text(dt)),
241 Data::DateTimeIso(s) | Data::DurationIso(s) => Value::String(s.clone()),
242 Data::Error(e) => Value::String(format!("{e:?}")),
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use serde_json::json;
250
251 #[test]
252 fn a_workbook_round_trips_with_its_types() {
253 let recs = vec![
254 json!({"id": 1, "name": "ada", "ok": true}),
255 json!({"id": 2, "name": "grace", "ok": false}),
256 ];
257 let bytes = encode(&recs, None).expect("encode");
258 let back = decode(&bytes, None, 0).expect("decode");
259 assert_eq!(back, recs, "numbers and booleans survive as themselves");
260 }
261
262 #[test]
263 fn columns_are_the_union_and_a_missing_cell_is_null() {
264 let bytes = encode(&[json!({"a": 1}), json!({"b": 2})], None).expect("encode");
265 let back = decode(&bytes, None, 0).expect("decode");
266 assert_eq!(
267 back,
268 vec![json!({"a": 1, "b": null}), json!({"a": null, "b": 2})]
269 );
270 }
271
272 #[test]
273 fn repeated_or_colliding_headers_are_refused() {
274 let bytes = encode(
275 &[json!({"a": "x", "b": "x"}), json!({"a": 1, "b": 2})],
276 None,
277 )
278 .expect("encode");
279 let err = decode(&bytes, None, 1).expect_err("dup").to_string();
280 assert!(err.contains("`x` at columns 0 and 1"), "{err}");
281 let bytes = encode(
283 &[json!({"a": null, "b": "column_0"}), json!({"a": 1, "b": 2})],
284 None,
285 )
286 .expect("encode");
287 let err = decode(&bytes, None, 1).expect_err("blank").to_string();
288 assert!(err.contains("`column_0`"), "{err}");
289 }
290
291 #[test]
292 fn a_named_sheet_is_written_and_selected_back() {
293 let bytes = encode(&[json!({"a": 1})], Some("Data")).expect("encode");
294 assert!(decode(&bytes, Some("Data"), 0).is_ok());
295 assert!(decode(&bytes, Some("0"), 0).is_ok());
297 let err = decode(&bytes, Some("Nope"), 0).expect_err("missing sheet");
298 assert!(err.to_string().contains("available: Data"), "{err}");
299 }
300
301 #[test]
302 fn an_empty_workbook_reads_back_as_zero_records_not_an_error() {
303 let bytes = encode(&[], None).expect("encode");
306 assert!(decode(&bytes, None, 0).expect("decode").is_empty());
307 }
308
309 #[test]
310 fn a_header_row_beyond_the_sheet_is_an_error_naming_the_row() {
311 let bytes = encode(&[json!({"a": 1})], None).expect("encode");
312 let err = decode(&bytes, None, 99).expect_err("out of range");
313 assert!(err.to_string().contains("header_row 99"), "{err}");
314 }
315
316 #[test]
317 fn nested_structure_is_kept_as_json_text_rather_than_dropped() {
318 let bytes = encode(&[json!({"a": {"k": 1}})], None).expect("encode");
319 let back = decode(&bytes, None, 0).expect("decode");
320 assert_eq!(back, vec![json!({"a": r#"{"k":1}"#})]);
321 }
322
323 #[test]
324 fn a_sheet_index_out_of_range_names_the_count() {
325 let bytes = encode(&[json!({"a": 1})], None).expect("encode");
326 let err = decode(&bytes, Some("7"), 0).expect_err("index 7");
327 assert!(err.to_string().contains("out of range"), "{err}");
328 }
329
330 #[test]
331 fn a_non_string_header_cell_is_stringified_rather_than_dropped() {
332 let bytes = encode(&[json!({"2024": "q1", "flag": true})], None).expect("encode");
335 let back = decode(&bytes, None, 0).expect("decode");
336 assert_eq!(back, vec![json!({"2024": "q1", "flag": true})]);
337 }
338
339 #[test]
340 fn a_blank_cell_reads_back_as_null() {
341 let bytes = encode(&[json!({"a": 1, "b": null})], None).expect("encode");
342 let back = decode(&bytes, None, 0).expect("decode");
343 assert_eq!(back, vec![json!({"a": 1, "b": null})]);
344 }
345
346 #[test]
347 fn not_a_workbook_is_an_error_not_a_panic() {
348 let err = decode(b"id,name\n1,ada\n", None, 0).expect_err("csv is not xlsx");
349 assert!(err.to_string().contains("opening workbook"), "{err}");
350 }
351
352 #[test]
353 fn an_integral_double_reads_back_as_an_integer() {
354 assert_eq!(float_to_value(1.0), json!(1));
357 assert_eq!(float_to_value(-3.0), json!(-3));
358 assert_eq!(float_to_value(1.5), json!(1.5));
359 assert_eq!(float_to_value(f64::NAN), Value::Null);
360 }
361
362 #[test]
363 fn a_sheet_named_like_a_number_is_matched_by_name_first() {
364 let bytes = encode(&[json!({"a": 1})], Some("2024")).expect("encode");
365 assert!(decode(&bytes, Some("2024"), 0).is_ok());
367 }
368
369 #[test]
373 fn every_cell_variant_decodes_to_its_json_shape() {
374 use calamine::{CellErrorType, Data, ExcelDateTime, ExcelDateTimeType};
375 assert_eq!(cell_to_value(&Data::Empty), Value::Null);
376 assert_eq!(cell_to_value(&Data::String("s".into())), json!("s"));
377 assert_eq!(cell_to_value(&Data::Bool(true)), json!(true));
378 assert_eq!(cell_to_value(&Data::Int(-7)), json!(-7));
379 assert_eq!(cell_to_value(&Data::Float(2.5)), json!(2.5));
380 assert_eq!(cell_to_value(&Data::Float(3.0)), json!(3));
382 assert_eq!(
383 cell_to_value(&Data::DateTimeIso("2026-01-02T03:04:05".into())),
384 json!("2026-01-02T03:04:05")
385 );
386 assert_eq!(
387 cell_to_value(&Data::DurationIso("PT1H".into())),
388 json!("PT1H")
389 );
390 assert_eq!(
393 cell_to_value(&Data::Error(CellErrorType::Div0)),
394 json!("Div0")
395 );
396 let dt = |v, ty, is_1904| Data::DateTime(ExcelDateTime::new(v, ty, is_1904));
397 assert_eq!(
398 cell_to_value(&dt(45000.5, ExcelDateTimeType::DateTime, false)),
399 json!("2023-03-15T12:00:00")
400 );
401 assert_eq!(
402 cell_to_value(&dt(45000.0, ExcelDateTimeType::DateTime, false)),
403 json!("2023-03-15")
404 );
405 assert_eq!(
406 cell_to_value(&dt(43538.0, ExcelDateTimeType::DateTime, true)),
407 json!("2023-03-15"),
408 "the 1904 date system is honoured"
409 );
410 assert_eq!(
411 cell_to_value(&dt(1.5, ExcelDateTimeType::TimeDelta, false)),
412 json!("PT129600S")
413 );
414 assert_eq!(
415 cell_to_string(&dt(45000.0, ExcelDateTimeType::DateTime, false)),
416 "2023-03-15"
417 );
418 assert_eq!(
419 cell_to_value(&dt(1e12, ExcelDateTimeType::DateTime, false)),
420 json!("1000000000000")
421 );
422 }
423
424 #[test]
427 fn header_cells_stringify_without_leaking_the_debug_form() {
428 use calamine::Data;
429 assert_eq!(cell_to_string(&Data::String("name".into())), "name");
430 assert_eq!(cell_to_string(&Data::Empty), "");
431 assert_eq!(cell_to_string(&Data::Int(3)), "3");
432 assert_eq!(cell_to_string(&Data::Bool(true)), "true");
433 }
434
435 #[test]
438 fn a_non_finite_double_becomes_null_rather_than_panicking() {
439 assert_eq!(float_to_value(f64::NAN), Value::Null);
440 assert_eq!(float_to_value(f64::INFINITY), Value::Null);
441 assert_eq!(float_to_value(-0.0), json!(0));
442 }
443
444 #[test]
447 fn only_exactly_representable_numbers_are_written_as_numbers() {
448 use serde_json::Number;
449 let n = |v: &str| -> Number { serde_json::from_str(v).expect("number") };
450 assert_eq!(exact_f64(&n("0")), Some(0.0));
451 assert_eq!(exact_f64(&n("-1")), Some(-1.0));
452 assert_eq!(exact_f64(&n("9007199254740992")), Some(9007199254740992.0));
453 assert_eq!(
454 exact_f64(&n("-9007199254740992")),
455 Some(-9007199254740992.0)
456 );
457 assert_eq!(exact_f64(&n("9007199254740993")), None);
459 assert_eq!(exact_f64(&n("-9007199254740993")), None);
460 assert_eq!(exact_f64(&n("9223372036854775807")), None);
461 assert_eq!(exact_f64(&n("18446744073709551615")), None);
463 assert_eq!(exact_f64(&n("1.5")), Some(1.5));
465 assert_eq!(exact_f64(&n("1e300")), Some(1e300));
466 }
467
468 #[test]
472 fn an_integer_past_2_53_keeps_its_digits_instead_of_rounding() {
473 let recs = vec![json!({ "id": 9007199254740993i64, "small": 42 })];
474 let bytes = encode(&recs, None).expect("encode");
475 let back = decode(&bytes, None, 0).expect("decode");
476 assert_eq!(back.len(), 1);
477 assert_eq!(
478 back[0]["id"],
479 Value::String("9007199254740993".into()),
480 "the exact digits must survive, as text"
481 );
482 assert_eq!(back[0]["small"], json!(42));
484 }
485
486 #[test]
489 fn a_date_cell_reads_back_as_a_date() {
490 use rust_xlsxwriter::{ExcelDateTime, Format, Workbook};
491 let mut wb = Workbook::new();
492 let ws = wb.add_worksheet();
493 ws.write_string(0, 0, "day").unwrap();
494 ws.write_string(0, 1, "at").unwrap();
495 let date = Format::new().set_num_format("yyyy-mm-dd");
496 let stamp = Format::new().set_num_format("yyyy-mm-dd hh:mm:ss");
497 ws.write_datetime_with_format(1, 0, ExcelDateTime::from_ymd(2023, 3, 15).unwrap(), &date)
498 .unwrap();
499 ws.write_datetime_with_format(
500 1,
501 1,
502 ExcelDateTime::parse_from_str("2023-03-15 12:30:00").unwrap(),
503 &stamp,
504 )
505 .unwrap();
506 let bytes = wb.save_to_buffer().unwrap();
507 assert_eq!(
508 decode(&bytes, None, 0).unwrap(),
509 vec![json!({"day": "2023-03-15", "at": "2023-03-15T12:30:00"})]
510 );
511 }
512}