use serde_json::Value;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
fn run_cli(args: &[&str]) -> std::process::Output {
Command::new(assert_cmd::cargo::cargo_bin!("agent-spreadsheet"))
.args(args)
.output()
.expect("run agent-spreadsheet")
}
fn parse_stdout_json(output: &std::process::Output) -> Value {
let stdout = String::from_utf8(output.stdout.clone()).expect("stdout utf8");
serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"invalid json in stdout: {}\nstdout: {}\nstderr: {}",
e,
stdout,
String::from_utf8_lossy(&output.stderr)
)
})
}
fn assert_success(output: &std::process::Output) {
assert!(
output.status.success(),
"command failed.\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn write_fixture(path: &Path) {
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook
.get_sheet_by_name_mut("Sheet1")
.expect("default sheet");
sheet.get_cell_mut("A1").set_value("Name");
sheet.get_cell_mut("B1").set_value("Amount");
sheet.get_cell_mut("C1").set_value("Total");
sheet.get_cell_mut("A2").set_value("Alice");
sheet.get_cell_mut("B2").set_value_number(10.0);
sheet.get_cell_mut("C2").set_formula("B2*2");
sheet.get_cell_mut("A3").set_value("Bob");
sheet.get_cell_mut("B3").set_value_number(20.0);
sheet.get_cell_mut("C3").set_formula("B3*2");
sheet.get_cell_mut("A4").set_value("Carol");
sheet.get_cell_mut("B4").set_value_number(30.0);
sheet.get_cell_mut("C4").set_formula("B4*2");
}
workbook.new_sheet("Summary").expect("add summary sheet");
{
let s = workbook.get_sheet_by_name_mut("Summary").expect("summary");
s.get_cell_mut("A1").set_value("Flag");
s.get_cell_mut("B1").set_value("Ready");
s.get_cell_mut("A2").set_value_number(42.0);
}
umya_spreadsheet::writer::xlsx::write(&workbook, path).expect("write fixture");
}
fn write_modified_fixture(path: &Path) {
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook
.get_sheet_by_name_mut("Sheet1")
.expect("default sheet");
sheet.get_cell_mut("A1").set_value("Name");
sheet.get_cell_mut("B1").set_value("Amount");
sheet.get_cell_mut("C1").set_value("Total");
sheet.get_cell_mut("A2").set_value("Eve"); sheet.get_cell_mut("B2").set_value_number(99.0); sheet.get_cell_mut("C2").set_formula("B2*2");
sheet.get_cell_mut("A3").set_value("Bob");
sheet.get_cell_mut("B3").set_value_number(20.0);
sheet.get_cell_mut("C3").set_formula("B3*2");
sheet.get_cell_mut("A4").set_value("Carol");
sheet.get_cell_mut("B4").set_value_number(30.0);
sheet.get_cell_mut("C4").set_formula("B4*2");
}
workbook.new_sheet("Summary").expect("add summary sheet");
{
let s = workbook.get_sheet_by_name_mut("Summary").expect("summary");
s.get_cell_mut("A1").set_value("Flag");
s.get_cell_mut("B1").set_value("Done"); s.get_cell_mut("A2").set_value_number(42.0);
}
umya_spreadsheet::writer::xlsx::write(&workbook, path).expect("write modified fixture");
}
fn write_formula_fixture(path: &Path) {
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook
.get_sheet_by_name_mut("Sheet1")
.expect("default sheet");
sheet.get_cell_mut("A1").set_value("Item");
sheet.get_cell_mut("B1").set_value("Value");
for row in 2..=10u32 {
sheet
.get_cell_mut(format!("A{}", row))
.set_value(format!("Item{}", row - 1));
sheet
.get_cell_mut(format!("B{}", row))
.set_value_number((row as f64) * 10.0);
}
sheet.get_cell_mut("B11").set_formula("SUM(B2:B10)");
sheet.get_cell_mut("C2").set_formula("B2+B3");
sheet.get_cell_mut("C3").set_formula("$B$2+B3");
}
umya_spreadsheet::writer::xlsx::write(&workbook, path).expect("write formula fixture");
}
#[test]
fn range_values_format_rows_produces_keyed_output() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("rows.xlsx");
write_fixture(&path);
let file = path.to_str().unwrap();
let out = run_cli(&["range-values", file, "Sheet1", "A1:C3", "--format", "rows"]);
assert_success(&out);
let json = parse_stdout_json(&out);
let entry = &json["values"][0];
let rows_keyed = entry["rows_keyed"]
.as_array()
.expect("rows_keyed should be an array");
assert_eq!(rows_keyed.len(), 3, "should have 3 rows (A1:C3)");
let r1 = &rows_keyed[0];
assert_eq!(r1["row"], 1);
assert_eq!(r1["cells"]["A"], "Name");
assert_eq!(r1["cells"]["B"], "Amount");
assert_eq!(r1["cells"]["C"], "Total");
let r2 = &rows_keyed[1];
assert_eq!(r2["row"], 2);
assert_eq!(r2["cells"]["A"], "Alice");
assert!(r2["cells"]["B"].is_number(), "B2 should be numeric");
}
#[test]
fn range_values_format_rows_omits_empty_cells() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("rows_sparse.xlsx");
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook.get_sheet_by_name_mut("Sheet1").unwrap();
sheet.get_cell_mut("A1").set_value("X");
sheet.get_cell_mut("C1").set_value("Y");
}
umya_spreadsheet::writer::xlsx::write(&workbook, &path).unwrap();
let out = run_cli(&[
"range-values",
path.to_str().unwrap(),
"Sheet1",
"A1:C1",
"--format",
"rows",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let rows = json["values"][0]["rows_keyed"].as_array().unwrap();
assert_eq!(rows.len(), 1);
let cells = rows[0]["cells"].as_object().unwrap();
assert!(cells.contains_key("A"));
assert!(cells.contains_key("C"));
assert!(!cells.contains_key("B"), "empty cell B should be omitted");
}
#[test]
fn range_values_format_rows_on_summary_sheet() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("rows_summary.xlsx");
write_fixture(&path);
let out = run_cli(&[
"range-values",
path.to_str().unwrap(),
"Summary",
"A1:B2",
"--format",
"rows",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let rows = json["values"][0]["rows_keyed"].as_array().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["cells"]["A"], "Flag");
assert_eq!(rows[0]["cells"]["B"], "Ready");
}
#[test]
fn inspect_cells_budget_raises_limit() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("budget.xlsx");
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook.get_sheet_by_name_mut("Sheet1").unwrap();
for row in 1..=10u32 {
for col in 1..=5u32 {
sheet
.get_cell_mut((col, row))
.set_value(format!("R{}C{}", row, col));
}
}
}
umya_spreadsheet::writer::xlsx::write(&workbook, &path).unwrap();
let default_out = run_cli(&["inspect-cells", path.to_str().unwrap(), "Sheet1", "A1:E10"]);
assert!(
!default_out.status.success(),
"default budget should reject 50 cells"
);
let budget_out = run_cli(&[
"inspect-cells",
path.to_str().unwrap(),
"Sheet1",
"A1:E10",
"--budget",
"50",
]);
assert_success(&budget_out);
let json = parse_stdout_json(&budget_out);
let budget_meta = &json["budget"];
assert!(
budget_meta["max_cells"].as_u64().unwrap() >= 50,
"budget max_cells should be >= 50, got: {}",
budget_meta
);
}
#[test]
fn inspect_cells_budget_rejects_out_of_range() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("budget_reject.xlsx");
write_fixture(&path);
let out0 = run_cli(&[
"inspect-cells",
path.to_str().unwrap(),
"Sheet1",
"A1",
"--budget",
"0",
]);
assert!(!out0.status.success(), "budget 0 should be rejected");
let stderr0 = String::from_utf8_lossy(&out0.stderr);
assert!(stderr0.contains("between 1 and 200"), "stderr: {}", stderr0);
let out201 = run_cli(&[
"inspect-cells",
path.to_str().unwrap(),
"Sheet1",
"A1",
"--budget",
"201",
]);
assert!(!out201.status.success(), "budget 201 should be rejected");
let stderr201 = String::from_utf8_lossy(&out201.stderr);
assert!(
stderr201.contains("between 1 and 200"),
"stderr: {}",
stderr201
);
}
#[test]
fn inspect_cells_budget_200_accepts_large_range() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("budget_large.xlsx");
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook.get_sheet_by_name_mut("Sheet1").unwrap();
for row in 1..=20u32 {
for col in 1..=10u32 {
sheet
.get_cell_mut((col, row))
.set_value(format!("R{}C{}", row, col));
}
}
}
umya_spreadsheet::writer::xlsx::write(&workbook, &path).unwrap();
let out = run_cli(&[
"inspect-cells",
path.to_str().unwrap(),
"Sheet1",
"A1:J20",
"--budget",
"200",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
assert!(
json["cells"].as_array().map(|a| a.len()).unwrap_or(0) > 0,
"should return cells"
);
}
#[test]
fn recalculate_changed_cells_shows_summary() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("recalc.xlsx");
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook.get_sheet_by_name_mut("Sheet1").unwrap();
sheet.get_cell_mut("A1").set_value_number(10.0);
sheet.get_cell_mut("A2").set_value_number(20.0);
sheet.get_cell_mut("A3").set_formula("A1+A2");
}
umya_spreadsheet::writer::xlsx::write(&workbook, &path).unwrap();
let output = tmp.path().join("recalc_out.xlsx");
let out = run_cli(&[
"recalculate",
path.to_str().unwrap(),
"--output",
output.to_str().unwrap(),
"--changed-cells",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let summary = &json["changed_cells_summary"];
assert!(
!summary.is_null(),
"expected changed_cells_summary in response: {}",
json
);
assert!(summary["total_changed"].is_number());
assert!(summary["by_sheet"].is_object());
assert!(summary["samples"].is_array());
}
#[test]
fn recalculate_without_changed_cells_flag_omits_summary() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("recalc_no_flag.xlsx");
write_fixture(&path);
let output = tmp.path().join("recalc_no_flag_out.xlsx");
let out = run_cli(&[
"recalculate",
path.to_str().unwrap(),
"--output",
output.to_str().unwrap(),
]);
assert_success(&out);
let json = parse_stdout_json(&out);
assert!(
json.get("changed_cells_summary").is_none() || json["changed_cells_summary"].is_null(),
"changed_cells_summary should be absent without flag: {}",
json
);
}
#[test]
fn recalculate_ignore_sheets_excludes_from_summary() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("recalc_ignore.xlsx");
let mut workbook = umya_spreadsheet::new_file();
{
let sheet = workbook.get_sheet_by_name_mut("Sheet1").unwrap();
sheet.get_cell_mut("A1").set_value_number(5.0);
sheet.get_cell_mut("A2").set_formula("A1*2");
}
workbook.new_sheet("Ignored").unwrap();
{
let sheet = workbook.get_sheet_by_name_mut("Ignored").unwrap();
sheet.get_cell_mut("A1").set_value_number(99.0);
sheet.get_cell_mut("A2").set_formula("A1+1");
}
umya_spreadsheet::writer::xlsx::write(&workbook, &path).unwrap();
let output = tmp.path().join("recalc_ignore_out.xlsx");
let out = run_cli(&[
"recalculate",
path.to_str().unwrap(),
"--output",
output.to_str().unwrap(),
"--changed-cells",
"--ignore-sheets",
"Ignored",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let summary = &json["changed_cells_summary"];
assert!(!summary.is_null());
if let Some(ignored) = summary.get("ignored_sheets") {
let ignored_arr = ignored.as_array().unwrap();
assert!(
ignored_arr.iter().any(|v| v == "Ignored"),
"expected 'Ignored' in ignored_sheets: {}",
summary
);
}
if let Some(by_sheet) = summary["by_sheet"].as_object() {
assert!(
!by_sheet.contains_key("Ignored"),
"Ignored sheet should not appear in by_sheet: {:?}",
by_sheet
);
}
}
#[test]
fn diff_sheets_filter_limits_to_specified_sheets() {
let tmp = tempdir().expect("tempdir");
let original = tmp.path().join("diff_orig.xlsx");
let modified = tmp.path().join("diff_mod.xlsx");
write_fixture(&original);
write_modified_fixture(&modified);
let out_s1 = run_cli(&[
"diff",
original.to_str().unwrap(),
modified.to_str().unwrap(),
"--sheets",
"Sheet1",
"--details",
]);
assert_success(&out_s1);
let json_s1 = parse_stdout_json(&out_s1);
if let Some(changes) = json_s1["changes"].as_array() {
for change in changes {
let sheet = change
.get("sheet")
.or_else(|| change.get("sheet_name"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
sheet.to_lowercase(),
"sheet1",
"expected only Sheet1 changes with --sheets Sheet1"
);
}
}
let out_sum = run_cli(&[
"diff",
original.to_str().unwrap(),
modified.to_str().unwrap(),
"--sheets",
"Summary",
"--details",
]);
assert_success(&out_sum);
let json_sum = parse_stdout_json(&out_sum);
let total_changes = json_sum["change_count"].as_u64().unwrap_or(0);
assert!(total_changes >= 1, "should have at least 1 Summary change");
if let Some(changes) = json_sum["changes"].as_array() {
for change in changes {
let sheet = change
.get("sheet")
.or_else(|| change.get("sheet_name"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
sheet.to_lowercase(),
"summary",
"expected only Summary changes with --sheets Summary"
);
}
}
}
#[test]
fn diff_sheets_multi_filter_includes_both() {
let tmp = tempdir().expect("tempdir");
let original = tmp.path().join("diff_multi_orig.xlsx");
let modified = tmp.path().join("diff_multi_mod.xlsx");
write_fixture(&original);
write_modified_fixture(&modified);
let out = run_cli(&[
"diff",
original.to_str().unwrap(),
modified.to_str().unwrap(),
"--sheets",
"Sheet1,Summary",
"--details",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let total = json["change_count"].as_u64().unwrap_or(0);
assert!(
total >= 2,
"should have changes from both sheets, got: {}",
total
);
}
#[test]
fn diff_sheet_and_sheets_mutually_exclusive() {
let tmp = tempdir().expect("tempdir");
let original = tmp.path().join("diff_excl_orig.xlsx");
let modified = tmp.path().join("diff_excl_mod.xlsx");
write_fixture(&original);
write_modified_fixture(&modified);
let out = run_cli(&[
"diff",
original.to_str().unwrap(),
modified.to_str().unwrap(),
"--sheet",
"Sheet1",
"--sheets",
"Summary",
]);
assert!(
!out.status.success(),
"expected mutual exclusivity error for --sheet + --sheets"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("mutually exclusive"),
"expected 'mutually exclusive' error, got: {}",
stderr
);
}
#[test]
fn diff_sheets_case_insensitive() {
let tmp = tempdir().expect("tempdir");
let original = tmp.path().join("diff_case_orig.xlsx");
let modified = tmp.path().join("diff_case_mod.xlsx");
write_fixture(&original);
write_modified_fixture(&modified);
let out = run_cli(&[
"diff",
original.to_str().unwrap(),
modified.to_str().unwrap(),
"--sheets",
"sheet1",
"--details",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
assert!(
json["change_count"].as_u64().unwrap_or(0) >= 1,
"case-insensitive sheet filter should match Sheet1"
);
}
#[test]
fn check_ref_impact_runs_without_mutation() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("impact.xlsx");
write_formula_fixture(&path);
let before_bytes = std::fs::read(&path).unwrap();
let ops_path = tmp.path().join("structure_ops.json");
let ops_payload = serde_json::json!({
"ops": [{
"kind": "insert_rows",
"sheet_name": "Sheet1",
"at_row": 5,
"count": 1
}]
});
std::fs::write(&ops_path, serde_json::to_string(&ops_payload).unwrap()).unwrap();
let out = run_cli(&[
"check-ref-impact",
path.to_str().unwrap(),
"--ops",
&format!("@{}", ops_path.display()),
]);
assert_success(&out);
let json = parse_stdout_json(&out);
assert!(
json.get("shifted_spans").is_some()
|| json.get("impact_report").is_some()
|| json.get("impact").is_some(),
"expected impact data in response: {}",
json
);
let after_bytes = std::fs::read(&path).unwrap();
assert_eq!(
before_bytes, after_bytes,
"check-ref-impact should not modify the workbook"
);
}
#[test]
fn check_ref_impact_with_formula_delta() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("impact_delta.xlsx");
write_formula_fixture(&path);
let ops_path = tmp.path().join("delta_ops.json");
let ops_payload = serde_json::json!({
"ops": [{
"kind": "insert_rows",
"sheet_name": "Sheet1",
"at_row": 5,
"count": 2
}]
});
std::fs::write(&ops_path, serde_json::to_string(&ops_payload).unwrap()).unwrap();
let out = run_cli(&[
"check-ref-impact",
path.to_str().unwrap(),
"--ops",
&format!("@{}", ops_path.display()),
"--show-formula-delta",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let has_delta =
json.get("formula_delta_preview").is_some() || json.get("formula_delta").is_some();
assert!(
has_delta,
"expected formula delta preview with --show-formula-delta: {}",
json
);
}
#[test]
fn check_ref_impact_delete_rows_detects_affected_formulas() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("impact_delete.xlsx");
write_formula_fixture(&path);
let ops_path = tmp.path().join("delete_ops.json");
let ops_payload = serde_json::json!({
"ops": [{
"kind": "delete_rows",
"sheet_name": "Sheet1",
"start_row": 3,
"count": 1
}]
});
std::fs::write(&ops_path, serde_json::to_string(&ops_payload).unwrap()).unwrap();
let out = run_cli(&[
"check-ref-impact",
path.to_str().unwrap(),
"--ops",
&format!("@{}", ops_path.display()),
"--show-formula-delta",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let report_str = serde_json::to_string(&json).unwrap();
assert!(
report_str.contains("tokens_affected")
|| report_str.contains("shifted_spans")
|| report_str.contains("formulas_affected")
|| report_str.contains("impact"),
"expected formula impact data in response: {}",
json
);
let before = std::fs::metadata(&path).unwrap().len();
assert!(before > 0, "fixture should exist");
}
#[test]
fn range_values_format_rows_empty_sheet() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("empty_rows.xlsx");
let workbook = umya_spreadsheet::new_file();
umya_spreadsheet::writer::xlsx::write(&workbook, &path).unwrap();
let out = run_cli(&[
"range-values",
path.to_str().unwrap(),
"Sheet1",
"A1:C3",
"--format",
"rows",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
let entry = &json["values"][0];
if let Some(rows) = entry.get("rows_keyed").and_then(|v| v.as_array()) {
for row in rows {
if let Some(cells) = row.get("cells").and_then(|v| v.as_object()) {
assert!(cells.is_empty(), "empty sheet should have no cell values");
}
}
}
}
#[test]
fn diff_nonexistent_sheet_filter_returns_zero_changes() {
let tmp = tempdir().expect("tempdir");
let original = tmp.path().join("diff_no_sheet_orig.xlsx");
let modified = tmp.path().join("diff_no_sheet_mod.xlsx");
write_fixture(&original);
write_modified_fixture(&modified);
let out = run_cli(&[
"diff",
original.to_str().unwrap(),
modified.to_str().unwrap(),
"--sheets",
"NonexistentSheet",
"--details",
]);
assert_success(&out);
let json = parse_stdout_json(&out);
assert_eq!(
json["change_count"].as_u64().unwrap_or(0),
0,
"filtering by nonexistent sheet should yield 0 changes"
);
}