#![cfg(feature = "xlsx")]
use std::io::Cursor;
use calamine::{Reader, Xlsx};
use crate::error::{Error, Result};
use crate::tools::workspace::{Workspace, Wrote};
fn unreadable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("{rel} is not a readable .xlsx workbook: {e}"))
}
fn unwritable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("cannot build the .xlsx for {rel}: {e}"))
}
fn open(ws: &Workspace, rel: &str) -> Result<Xlsx<Cursor<Vec<u8>>>> {
let bytes = ws.read_bytes(rel)?;
Xlsx::new(Cursor::new(bytes)).map_err(|e| unreadable(rel, e))
}
fn is_a1(cell: &str) -> bool {
regex::Regex::new(r"^\$?[A-Za-z]{1,3}\$?[1-9][0-9]*$").is_ok_and(|re| re.is_match(cell))
}
pub fn sheet_names(ws: &Workspace, rel: &str) -> Result<Vec<String>> {
Ok(open(ws, rel)?.sheet_names())
}
pub fn read_sheet(ws: &Workspace, rel: &str, sheet: Option<&str>) -> Result<String> {
let mut book = open(ws, rel)?;
let names = book.sheet_names();
let name = match sheet {
Some(s) if names.iter().any(|n| n == s) => s.to_string(),
Some(s) => {
return Err(Error::Config(format!(
"{rel} has no sheet named {s:?}; it has: {}",
names.join(", ")
)))
}
None => names
.first()
.cloned()
.ok_or_else(|| Error::Config(format!("{rel} contains no sheets")))?,
};
let range = book
.worksheet_range(&name)
.map_err(|e| unreadable(rel, e))?;
let Some((first_row, first_col)) = range.start() else {
return Ok(format!("{name}: empty sheet"));
};
let mut out = String::new();
for c in 0..range.width() {
out.push('\t');
out.push_str(&rust_xlsxwriter::utility::column_number_to_name(
(first_col + c as u32) as u16,
));
}
out.push('\n');
for (i, row) in range.rows().enumerate() {
out.push_str(&(first_row + i as u32 + 1).to_string());
for cell in row {
out.push('\t');
out.push_str(&cell.to_string().replace(['\t', '\n', '\r'], " "));
}
out.push('\n');
}
Ok(out)
}
pub fn write_new(ws: &Workspace, rel: &str, sheet: &str, rows: &[Vec<String>]) -> Result<Wrote> {
let mut book = rust_xlsxwriter::Workbook::new();
let w = book.add_worksheet();
w.set_name(sheet).map_err(|e| unwritable(rel, e))?;
for (r, row) in rows.iter().enumerate() {
for (c, value) in row.iter().enumerate() {
w.write_string(r as u32, c as u16, value)
.map_err(|e| unwritable(rel, e))?;
}
}
let buf = book.save_to_buffer().map_err(|e| unwritable(rel, e))?;
ws.write_bytes(rel, &buf)
}
pub fn set_cell(ws: &Workspace, rel: &str, sheet: &str, cell: &str, value: &str) -> Result<Wrote> {
if !is_a1(cell) {
return Err(Error::Config(format!(
"{cell:?} is not an A1-style cell reference (like B7)"
)));
}
let bytes = ws.read_bytes(rel)?;
let mut book = umya_spreadsheet::reader::xlsx::read_reader(Cursor::new(bytes), true)
.map_err(|e| unreadable(rel, e))?;
book.sheet_by_name_mut(sheet)
.map_err(|e| Error::Config(format!("{rel} has no sheet named {sheet:?}: {e}")))?
.cell_mut(cell)
.set_value(value);
let mut buf = Vec::new();
umya_spreadsheet::writer::xlsx::write_writer(&book, &mut buf)
.map_err(|e| unwritable(rel, e))?;
ws.write_bytes(rel, &buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::Policy;
fn dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn guarded(root: &std::path::Path) -> Workspace {
Workspace::with_policy(
root,
Policy::default()
.layer("base")
.allow_read("*")
.allow_write("*")
.deny_read("secrets/*")
.deny_write("secrets/*"),
)
}
fn two_sheet_fixture(ws: &Workspace, rel: &str) {
let mut book = umya_spreadsheet::new_file();
let first = book.sheet_by_name_mut("Sheet1").unwrap();
first.set_name("Data");
first.cell_mut("A1").set_value("Region");
first.cell_mut("B1").set_value("Q1");
first.cell_mut("A2").set_value("EMEA");
first.cell_mut("B2").set_value("120");
first.style_mut("A1").font_mut().set_bold(true);
let notes = book.new_sheet("Notes").unwrap();
notes.cell_mut("A1").set_value("do not lose me");
let mut buf = Vec::new();
umya_spreadsheet::writer::xlsx::write_writer(&book, &mut buf).unwrap();
ws.write_bytes(rel, &buf).unwrap();
}
fn foreign_fixture(ws: &Workspace, rel: &str) {
let mut book = rust_xlsxwriter::Workbook::new();
let bold = rust_xlsxwriter::Format::new().set_bold();
let data = book.add_worksheet();
data.set_name("Data").unwrap();
data.write_string_with_format(0, 0, "Region", &bold)
.unwrap();
data.write_string(0, 1, "Q1").unwrap();
data.write_string(1, 0, "EMEA").unwrap();
data.write_string(1, 1, "120").unwrap();
let notes = book.add_worksheet();
notes.set_name("Notes").unwrap();
notes.write_string(0, 0, "do not lose me").unwrap();
ws.write_bytes(rel, &book.save_to_buffer().unwrap())
.unwrap();
}
#[test]
fn an_edit_preserves_a_workbook_the_editing_crate_did_not_write() {
let d = dir();
let ws = Workspace::new(d.path());
foreign_fixture(&ws, "book.xlsx");
assert_eq!(
set_cell(&ws, "book.xlsx", "Data", "B2", "999").unwrap(),
Wrote::Changed
);
let after = umya_spreadsheet::reader::xlsx::read_reader(
Cursor::new(ws.read_bytes("book.xlsx").unwrap()),
true,
)
.unwrap();
let data = after.sheet_by_name("Data").unwrap();
assert_eq!(data.value("B2"), "999", "the new value landed");
assert_eq!(data.value("A1"), "Region", "its neighbours survived");
assert!(
data.style("A1").font().is_some_and(|f| f.bold()),
"the bold format another crate wrote survived an edit to B2"
);
assert_eq!(
after.sheet_by_name("Notes").unwrap().value("A1"),
"do not lose me",
"the untouched sheet survived"
);
assert_eq!(
sheet_names(&ws, "book.xlsx").unwrap(),
vec!["Data", "Notes"],
"and no sheet was added or dropped"
);
}
#[test]
fn setting_one_cell_leaves_the_other_sheet_and_the_formatting_intact() {
let d = dir();
let ws = Workspace::new(d.path());
two_sheet_fixture(&ws, "book.xlsx");
assert_eq!(
set_cell(&ws, "book.xlsx", "Data", "B2", "999").unwrap(),
Wrote::Changed
);
let after = umya_spreadsheet::reader::xlsx::read_reader(
Cursor::new(ws.read_bytes("book.xlsx").unwrap()),
true,
)
.unwrap();
let data = after.sheet_by_name("Data").unwrap();
assert_eq!(data.value("B2"), "999", "the new value landed");
assert_eq!(data.value("A1"), "Region", "its neighbours survived");
assert_eq!(data.value("A2"), "EMEA");
assert!(
data.style("A1").font().is_some_and(|f| f.bold()),
"the bold format on A1 survived an edit to B2"
);
assert_eq!(
after.sheet_by_name("Notes").unwrap().value("A1"),
"do not lose me",
"the untouched sheet survived"
);
assert_eq!(
sheet_names(&ws, "book.xlsx").unwrap(),
vec!["Data", "Notes"],
"and no sheet was added or dropped"
);
}
#[test]
fn write_new_then_read_sheet_round_trips_the_values() {
let d = dir();
let ws = Workspace::new(d.path());
let rows = vec![
vec!["Region".to_string(), "Q1".to_string()],
vec!["EMEA".to_string(), "120".to_string()],
];
assert_eq!(
write_new(&ws, "out/report.xlsx", "Sales", &rows).unwrap(),
Wrote::Created
);
let text = read_sheet(&ws, "out/report.xlsx", None).unwrap();
assert_eq!(text, "\tA\tB\n1\tRegion\tQ1\n2\tEMEA\t120\n", "{text}");
assert_eq!(
read_sheet(&ws, "out/report.xlsx", Some("Sales")).unwrap(),
text
);
}
#[test]
fn sheet_names_lists_every_sheet_in_workbook_order() {
let d = dir();
let ws = Workspace::new(d.path());
two_sheet_fixture(&ws, "book.xlsx");
assert_eq!(
sheet_names(&ws, "book.xlsx").unwrap(),
vec!["Data", "Notes"]
);
}
#[test]
fn a_denied_path_is_refused_for_both_reading_and_editing() {
let d = dir();
two_sheet_fixture(&Workspace::new(d.path()), "secrets/book.xlsx");
let ws = guarded(d.path());
let read = read_sheet(&ws, "secrets/book.xlsx", None);
assert!(
matches!(&read, Err(Error::Refused { act, target, .. })
if act == "read" && target == "secrets/book.xlsx"),
"got {read:?}"
);
let edit = set_cell(&ws, "secrets/book.xlsx", "Data", "B2", "999");
assert!(
matches!(&edit, Err(Error::Refused { act, .. }) if act == "read"),
"the edit is stopped at the read, before a byte of it is parsed: got {edit:?}"
);
}
#[test]
fn the_same_operations_succeed_on_a_path_the_policy_allows() {
let d = dir();
let ws = guarded(d.path());
two_sheet_fixture(&ws, "open/book.xlsx");
assert!(read_sheet(&ws, "open/book.xlsx", None)
.unwrap()
.contains("EMEA"));
assert_eq!(
set_cell(&ws, "open/book.xlsx", "Data", "B2", "999").unwrap(),
Wrote::Changed
);
}
#[test]
fn a_file_that_is_not_a_workbook_is_an_error_not_a_panic() {
let d = dir();
let ws = Workspace::new(d.path());
ws.write_bytes("notes.xlsx", b"this is plainly not a zip archive")
.unwrap();
for err in [
sheet_names(&ws, "notes.xlsx").unwrap_err(),
read_sheet(&ws, "notes.xlsx", None).unwrap_err(),
set_cell(&ws, "notes.xlsx", "Data", "A1", "x").unwrap_err(),
] {
let shown = err.to_string();
assert!(
shown.contains("notes.xlsx") && shown.contains("not a readable"),
"the message names the file and what is wrong with it, got {shown}"
);
}
}
#[test]
fn a_malformed_cell_reference_is_rejected_before_it_reaches_the_library() {
let d = dir();
let ws = Workspace::new(d.path());
two_sheet_fixture(&ws, "book.xlsx");
for bad in ["", "A", "1", "A0", "Data!A1", "AAAA1", "B 2"] {
let e = set_cell(&ws, "book.xlsx", "Data", bad, "x").unwrap_err();
assert!(
e.to_string().contains("A1-style"),
"{bad:?} should be refused as a reference, got {e}"
);
}
for good in ["B2", "b2", "$B$2", "AA100"] {
assert!(
set_cell(&ws, "book.xlsx", "Data", good, "x").is_ok(),
"{good:?} is a valid reference"
);
}
}
#[test]
fn naming_a_sheet_that_is_not_there_lists_the_ones_that_are() {
let d = dir();
let ws = Workspace::new(d.path());
two_sheet_fixture(&ws, "book.xlsx");
let e = read_sheet(&ws, "book.xlsx", Some("Summary")).unwrap_err();
let shown = e.to_string();
assert!(
shown.contains("Summary") && shown.contains("Data") && shown.contains("Notes"),
"the error tells the model what it could have asked for, got {shown}"
);
}
}