#![cfg(feature = "recalc")]
use anyhow::Result;
use spreadsheet_mcp::model::WorkbookId;
use spreadsheet_mcp::tools::fork::{
CreateForkParams, ReplaceInFormulasParams, create_fork, replace_in_formulas,
};
use spreadsheet_mcp::tools::param_enums::BatchMode;
use spreadsheet_mcp::tools::{ListWorkbooksParams, list_workbooks};
mod support;
fn recalc_state(
workspace: &support::TestWorkspace,
) -> std::sync::Arc<spreadsheet_mcp::state::AppState> {
let config = workspace.config_with(|cfg| {
cfg.recalc_enabled = true;
});
support::app_state_with_config(config)
}
fn create_formula_workbook(workspace: &support::TestWorkspace, name: &str) -> std::path::PathBuf {
workspace.create_workbook(name, |book| {
let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
sheet.get_cell_mut("A1").set_value("Label");
sheet.get_cell_mut("B1").set_value("Value");
sheet
.get_cell_mut("B2")
.set_formula("SUM(C2:C10)".to_string());
sheet
.get_cell_mut("B3")
.set_formula("AVERAGE(C2:C10)".to_string());
sheet
.get_cell_mut("B4")
.set_formula("Sheet1!D5+Sheet1!D6".to_string());
sheet.get_cell_mut("B5").set_value("SUM(C2:C10)");
})
}
async fn fork_workbook(
workspace: &support::TestWorkspace,
state: &std::sync::Arc<spreadsheet_mcp::state::AppState>,
name: &str,
) -> Result<String> {
create_formula_workbook(workspace, name);
let list = list_workbooks(
state.clone(),
ListWorkbooksParams {
slug_prefix: None,
folder: None,
path_glob: None,
limit: None,
offset: None,
include_paths: None,
},
)
.await?;
let search = name.trim_end_matches(".xlsx");
let wb = list
.workbooks
.iter()
.find(|w| w.slug.contains(search))
.unwrap_or(&list.workbooks[0]);
let fork = create_fork(
state.clone(),
CreateForkParams {
workbook_or_fork_id: wb.workbook_id.clone(),
},
)
.await?;
Ok(fork.fork_id)
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_apply_changes_formulas() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "apply_test.xlsx").await?;
let response = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "C2:C10".to_string(),
replace: "D2:D20".to_string(),
range: None,
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: None,
},
)
.await?;
assert_eq!(response.formulas_changed, 2);
assert!(response.formulas_checked >= 3);
assert!(response.recalc_needed);
assert!(!response.samples.is_empty());
assert_eq!(response.mode, "apply");
assert!(response.change_id.is_none());
let fork_wb = state.open_workbook(&WorkbookId(fork_id.clone())).await?;
let (b2_formula, b3_formula) = fork_wb.with_sheet("Sheet1", |sheet| {
let b2 = sheet.get_cell("B2").unwrap().get_formula().to_string();
let b3 = sheet.get_cell("B3").unwrap().get_formula().to_string();
(b2, b3)
})?;
assert_eq!(b2_formula, "SUM(D2:D20)");
assert_eq!(b3_formula, "AVERAGE(D2:D20)");
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_preview_stages_change() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "preview_test.xlsx").await?;
let response = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "Sheet1!".to_string(),
replace: "Sheet2!".to_string(),
range: None,
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Preview),
label: Some("fix sheet refs".to_string()),
formula_parse_policy: None,
},
)
.await?;
assert_eq!(response.formulas_changed, 1);
assert_eq!(response.mode, "preview");
assert!(response.change_id.is_some());
let fork_wb = state.open_workbook(&WorkbookId(fork_id.clone())).await?;
let b4_formula = fork_wb.with_sheet("Sheet1", |sheet| {
sheet.get_cell("B4").unwrap().get_formula().to_string()
})?;
assert_eq!(b4_formula, "Sheet1!D5+Sheet1!D6");
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_regex_mode() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "regex_test.xlsx").await?;
let response = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: r"Sheet1!D(\d+)".to_string(),
replace: "Sheet2!E$1".to_string(),
range: None,
regex: true,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: None,
},
)
.await?;
assert_eq!(response.formulas_changed, 1);
let fork_wb = state.open_workbook(&WorkbookId(fork_id.clone())).await?;
let b4_formula = fork_wb.with_sheet("Sheet1", |sheet| {
sheet.get_cell("B4").unwrap().get_formula().to_string()
})?;
assert_eq!(b4_formula, "Sheet2!E5+Sheet2!E6");
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_range_scoped() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "range_scope.xlsx").await?;
let response = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "C2:C10".to_string(),
replace: "X1:X5".to_string(),
range: Some("B2:B2".to_string()),
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: None,
},
)
.await?;
assert_eq!(response.formulas_changed, 1, "only B2 in range");
let fork_wb = state.open_workbook(&WorkbookId(fork_id.clone())).await?;
let (b2, b3) = fork_wb.with_sheet("Sheet1", |sheet| {
let b2 = sheet.get_cell("B2").unwrap().get_formula().to_string();
let b3 = sheet.get_cell("B3").unwrap().get_formula().to_string();
(b2, b3)
})?;
assert_eq!(b2, "SUM(X1:X5)");
assert_eq!(b3, "AVERAGE(C2:C10)");
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_no_op() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "noop_test.xlsx").await?;
let response = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "NONEXISTENT".to_string(),
replace: "WHATEVER".to_string(),
range: None,
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: None,
},
)
.await?;
assert_eq!(response.formulas_changed, 0);
assert!(!response.recalc_needed);
assert!(
response
.warnings
.iter()
.any(|w| w.contains("WARN_NO_MATCH"))
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_fail_policy_rejects_invalid_replacements() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "fail_policy.xlsx").await?;
let err = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "SUM(".to_string(),
replace: "SUM((".to_string(),
range: None,
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: Some(spreadsheet_mcp::model::FormulaParsePolicy::Fail),
},
)
.await
.expect_err("fail policy should reject invalid replacements");
assert!(
err.to_string().contains("failed parse"),
"unexpected error: {err}"
);
let fork_wb = state.open_workbook(&WorkbookId(fork_id.clone())).await?;
let b2_formula = fork_wb.with_sheet("Sheet1", |sheet| {
sheet.get_cell("B2").unwrap().get_formula().to_string()
})?;
assert_eq!(b2_formula, "SUM(C2:C10)");
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn replace_in_formulas_noop_preserves_prior_recalc_needed_state() -> Result<()> {
let workspace = support::TestWorkspace::new();
let state = recalc_state(&workspace);
let fork_id = fork_workbook(&workspace, &state, "recalc_state.xlsx").await?;
let first = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "C2:C10".to_string(),
replace: "D2:D20".to_string(),
range: None,
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: None,
},
)
.await?;
assert!(first.recalc_needed);
let second = replace_in_formulas(
state.clone(),
ReplaceInFormulasParams {
fork_id: fork_id.clone(),
sheet_name: "Sheet1".to_string(),
find: "NONEXISTENT".to_string(),
replace: "WHATEVER".to_string(),
range: None,
regex: false,
case_sensitive: true,
mode: Some(BatchMode::Apply),
label: None,
formula_parse_policy: None,
},
)
.await?;
assert_eq!(second.formulas_changed, 0);
assert!(
second.recalc_needed,
"prior fork recalc_needed state should be preserved"
);
Ok(())
}