use crate::journal::{self, ChainStatus};
use crate::refshift::{Axis, Op, StructuralEdit};
use crate::structural::{self, StructuralReport};
use anyhow::{Context, Result};
use serde_json::{json, Value};
#[allow(clippy::too_many_arguments)]
pub fn run(
file: &str,
sheet: &str,
axis: Axis,
op: Op,
at: u32,
count: u32,
dest: u32,
dry_run: bool,
actor: Option<&str>,
) -> Result<Value> {
if at == 0 || count == 0 {
return Ok(json!({"command":"restructure","error":"bad_args",
"reason":"--at is 1-based and --count must be >= 1"}));
}
if op == Op::Move && dest == 0 {
return Ok(json!({"command":"restructure","error":"bad_args",
"reason":"move-rows requires --dest >= 1 (the 1-based row to move the block before)"}));
}
let edit = StructuralEdit {
axis,
at,
count,
op,
sheet: sheet.to_string(),
dest,
};
let op_str = op_name(op, axis);
let _lock = if dry_run {
None
} else {
Some(journal::lock(file)?)
};
let original =
std::fs::read(file).with_context(|| format!("read {}", crate::diff::basename(file)))?;
let base_hash = crate::hash::sha256_file(file)?;
let (new_bytes, report) = structural::structural_edit(&original, &edit)
.with_context(|| format!("structural edit on {sheet}"))?;
let reopen = reopen_ok(&new_bytes, file);
let summary = report_json(&report, &op_str, &edit, &reopen);
if dry_run {
return Ok(json!({
"command": "restructure",
"dry_run": true,
"base_hash": base_hash,
"edit": summary,
}));
}
if !report.residuals.is_empty() {
return Ok(json!({
"command": "restructure",
"dry_run": false,
"error": "residual_unreachable",
"reason": "the edit touches constructs the shift algebra cannot preserve by coordinate surgery",
"residuals": report.residuals.iter().map(|r| json!({
"part": r.part, "reason": r.reason, "detail": r.detail
})).collect::<Vec<_>>(),
}));
}
if let Err(detail) = &reopen {
return Ok(json!({
"command": "restructure",
"dry_run": false,
"error": "verification_failed",
"reason": "surgical output does not re-open in the engine",
"detail": detail,
}));
}
let result_hash = crate::hash::sha256_bytes(&new_bytes);
let timestamp = journal::iso_timestamp(None);
let resolved_actor = journal::resolve_actor(actor);
let disk_hash = crate::hash::sha256_file(file)?;
if disk_hash != base_hash {
return Ok(json!({"command":"restructure","dry_run":false,
"error":"revision_mismatch","expected":base_hash,"actual":disk_hash}));
}
match journal::chain_status(file, &disk_hash)? {
ChainStatus::Genesis | ChainStatus::Ok => {}
ChainStatus::ExternalEdit => {
let marker =
journal::append_adoption_marker(file, &disk_hash, ×tamp, &resolved_actor)?;
return Ok(json!({"command":"restructure","dry_run":false,
"error":"external_edit_detected","expected":marker.base_hash,
"actual":disk_hash,"adopted_rev":marker.rev}));
}
}
let rev = journal::next_rev(file)?;
let ops = if op == Op::Move {
json!([{ "type": op_str, "sheet": sheet, "at": at, "count": count, "dest": dest }])
} else {
json!([{ "type": op_str, "sheet": sheet, "at": at, "count": count }])
};
let receipt = journal::commit(
file,
&new_bytes,
rev,
"restructure",
&base_hash,
&result_hash,
ops,
×tamp,
&resolved_actor,
None,
None,
)?;
Ok(json!({
"command": "restructure",
"dry_run": false,
"rev": receipt.rev,
"base_hash": base_hash,
"result_hash": result_hash,
"edit": summary,
"verified": { "reopened": true, "residuals": 0 },
}))
}
fn report_json(
report: &StructuralReport,
op_str: &str,
edit: &StructuralEdit,
reopen: &Result<(), String>,
) -> Value {
json!({
"op": op_str,
"sheet": edit.sheet,
"at": edit.at,
"count": edit.count,
"dest": edit.dest,
"refs_shifted": report.refs_shifted,
"ref_errors": report.ref_errors,
"rows_inserted": report.rows_inserted,
"rows_deleted": report.rows_deleted,
"parts_touched": report.parts_touched,
"residuals": report.residuals.iter().map(|r| json!({
"part": r.part, "reason": r.reason
})).collect::<Vec<_>>(),
"reopens": reopen.is_ok(),
})
}
fn op_name(op: Op, axis: Axis) -> String {
match (op, axis) {
(Op::Insert, Axis::Row) => "insert_rows",
(Op::Delete, Axis::Row) => "delete_rows",
(Op::Insert, Axis::Col) => "insert_cols",
(Op::Delete, Axis::Col) => "delete_cols",
(Op::Move, Axis::Row) => "move_rows",
(Op::Move, Axis::Col) => "move_cols", }
.to_string()
}
fn reopen_ok(bytes: &[u8], near: &str) -> Result<(), String> {
let dir = std::path::Path::new(near)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let tmp = dir.join(format!(
".xlq-restructure-verify-{}-{}.xlsx",
std::process::id(),
SEQ.fetch_add(1, Ordering::SeqCst)
));
if let Err(e) = std::fs::write(&tmp, bytes) {
return Err(format!("write temp: {e}"));
}
let tmp_str = tmp.to_string_lossy().to_string();
let res = match ironcalc::import::load_from_xlsx(&tmp_str, "en", "UTC", "en") {
Ok(mut m) => {
m.evaluate();
Ok(())
}
Err(e) => Err(format!("{e}")),
};
let _ = std::fs::remove_file(&tmp);
res
}
#[cfg(test)]
mod tests {
use super::*;
const FIX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/structural/");
fn scratch(name: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir()
.join(format!("xlq-rs-{name}-{}-{n}.xlsx", std::process::id()))
.to_string_lossy()
.into_owned()
}
fn setup(tag: &str) -> String {
let dst = scratch(tag);
std::fs::copy(format!("{FIX}refs.xlsx"), &dst).unwrap();
dst
}
#[test]
fn dry_run_reports_shift_without_writing() {
let f = setup("dry");
let before = crate::hash::sha256_file(&f).unwrap();
let out = run(
&f,
"Sheet1",
Axis::Row,
Op::Insert,
5,
1,
0,
true,
Some("t"),
)
.unwrap();
assert_eq!(out["edit"]["reopens"], json!(true));
assert!(out["edit"]["refs_shifted"].as_u64().unwrap() >= 4);
assert_eq!(
crate::hash::sha256_file(&f).unwrap(),
before,
"dry run must not write"
);
std::fs::remove_file(&f).ok();
}
#[test]
fn move_requires_dest() {
let f = setup("movedest");
let out = run(&f, "Sheet1", Axis::Row, Op::Move, 5, 1, 0, true, Some("t")).unwrap();
assert_eq!(
out["error"],
json!("bad_args"),
"move without --dest must be refused: {out}"
);
std::fs::remove_file(&f).ok();
}
#[test]
fn read_error_carries_basename_only() {
let err = run(
"/tmp/xlq-secret-restr-dir/private_model.xlsx",
"Sheet1",
Axis::Row,
Op::Insert,
5,
1,
0,
true,
None,
)
.expect_err("missing file must fail");
let text = format!("{err:#}");
assert!(
text.contains("private_model.xlsx"),
"basename present: {text}"
);
assert!(
!text.contains("xlq-secret-restr-dir"),
"directory leaked: {text}"
);
}
#[test]
fn real_insert_commits_and_recomputes() {
let f = setup("real");
let out = run(
&f,
"Sheet1",
Axis::Row,
Op::Insert,
5,
1,
0,
false,
Some("t"),
)
.unwrap();
assert_eq!(out["rev"], json!(1), "got {out}");
assert_eq!(out["verified"]["reopened"], json!(true));
let mut m = ironcalc::import::load_from_xlsx(&f, "en", "UTC", "en").unwrap();
m.evaluate();
assert_eq!(m.get_formatted_cell_value(0, 12, 1).unwrap(), "55");
assert!(std::path::Path::new(&format!("{f}.xlq.jsonl")).exists());
std::fs::remove_file(&f).ok();
std::fs::remove_file(format!("{f}.rev-1.xlsx")).ok();
std::fs::remove_file(format!("{f}.xlq.jsonl")).ok();
}
#[test]
fn shared_formula_edit_now_succeeds() {
let fixture = format!("{FIX}shared.xlsx"); let dst = setup_from("shared", &fixture); let out = run(
&dst,
"Sheet1",
Axis::Row,
Op::Insert,
2,
1,
0,
false,
Some("t"),
)
.unwrap();
assert_eq!(out["rev"], json!(1), "shared edit should commit, got {out}");
assert_eq!(out["verified"]["reopened"], json!(true));
std::fs::remove_file(&dst).ok();
std::fs::remove_file(format!("{dst}.rev-1.xlsx")).ok();
std::fs::remove_file(format!("{dst}.xlq.jsonl")).ok();
}
#[test]
fn table_edit_still_refused() {
let fixture = format!("{FIX}table.xlsx");
let dst = setup_from("table", &fixture);
let out = run(
&dst,
"Sheet1",
Axis::Row,
Op::Insert,
3,
1,
0,
false,
Some("t"),
)
.unwrap();
assert_eq!(out["error"], json!("residual_unreachable"), "got {out}");
std::fs::remove_file(&dst).ok();
}
#[test]
fn real_move_commits_and_recomputes() {
let f = setup("move");
let out = run(&f, "Sheet1", Axis::Row, Op::Move, 2, 1, 4, false, Some("t")).unwrap();
assert!(
out.get("rev").is_some() || out["error"] == json!("residual_unreachable"),
"move must commit or be soundly refused, got {out}"
);
if out.get("rev").is_some() {
assert_eq!(out["verified"]["reopened"], json!(true));
let mut m = ironcalc::import::load_from_xlsx(&f, "en", "UTC", "en").unwrap();
m.evaluate();
}
std::fs::remove_file(&f).ok();
std::fs::remove_file(format!("{f}.rev-1.xlsx")).ok();
std::fs::remove_file(format!("{f}.xlq.jsonl")).ok();
}
fn setup_from(tag: &str, src: &str) -> String {
let dst = scratch(tag);
std::fs::copy(src, &dst).unwrap();
dst
}
}