spreadsheet_kit/cli/commands/
recalc.rs1use crate::runtime::stateless::StatelessRuntime;
2use anyhow::{Result, anyhow, bail};
3use serde::Serialize;
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use tempfile::Builder;
9
10#[derive(Debug, Serialize)]
11struct RecalculateResponse {
12 file: String,
13 backend: String,
14 duration_ms: u64,
15 cells_evaluated: Option<u64>,
16 eval_errors: Option<Vec<String>>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 source_path: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 target_path: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 changed: Option<bool>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 changed_cells_summary: Option<ChangedCellsSummary>,
25}
26
27#[derive(Debug, Serialize)]
28struct ChangedCellsSummary {
29 total_changed: u64,
30 by_sheet: BTreeMap<String, u64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 ignored_sheets: Option<Vec<String>>,
33 samples: Vec<ChangedCellSample>,
35}
36
37#[derive(Debug, Serialize)]
38struct ChangedCellSample {
39 sheet: String,
40 address: String,
41 before: String,
42 after: String,
43}
44
45fn snapshot_cell_values(
47 path: &Path,
48 ignore: &[String],
49) -> Result<BTreeMap<(String, String), String>> {
50 let book = umya_spreadsheet::reader::xlsx::read(path).map_err(|e| {
51 anyhow!(
52 "failed to read workbook '{}' for snapshot: {}",
53 path.display(),
54 e
55 )
56 })?;
57 let mut cells = BTreeMap::new();
58
59 for sheet in book.get_sheet_collection() {
60 let sheet_name = sheet.get_name().to_string();
61 if ignore.iter().any(|s| s == &sheet_name) {
62 continue;
63 }
64 for cell in sheet.get_cell_collection() {
65 let address = cell.get_coordinate().get_coordinate().to_string();
66 let value = cell.get_value().to_string();
67 cells.insert((sheet_name.clone(), address), value);
68 }
69 }
70
71 Ok(cells)
72}
73
74fn build_changed_cells_summary(
76 before: &BTreeMap<(String, String), String>,
77 after: &BTreeMap<(String, String), String>,
78 ignored_sheets: Option<Vec<String>>,
79) -> ChangedCellsSummary {
80 let mut by_sheet: BTreeMap<String, u64> = BTreeMap::new();
81 let mut samples: Vec<ChangedCellSample> = Vec::new();
82 let mut total_changed: u64 = 0;
83
84 let mut all_keys: Vec<&(String, String)> = before.keys().chain(after.keys()).collect();
86 all_keys.sort();
87 all_keys.dedup();
88
89 for key in all_keys {
90 let before_val = before.get(key).map(|s| s.as_str()).unwrap_or("");
91 let after_val = after.get(key).map(|s| s.as_str()).unwrap_or("");
92
93 if before_val != after_val {
94 total_changed += 1;
95 *by_sheet.entry(key.0.clone()).or_insert(0) += 1;
96
97 if samples.len() < 50 {
98 samples.push(ChangedCellSample {
99 sheet: key.0.clone(),
100 address: key.1.clone(),
101 before: before_val.to_string(),
102 after: after_val.to_string(),
103 });
104 }
105 }
106 }
107
108 ChangedCellsSummary {
109 total_changed,
110 by_sheet,
111 ignored_sheets,
112 samples,
113 }
114}
115
116pub async fn recalculate(
117 file: PathBuf,
118 output: Option<PathBuf>,
119 force: bool,
120 ignore_sheets: Option<Vec<String>>,
121 changed_cells: bool,
122) -> Result<Value> {
123 if force && output.is_none() {
124 bail!("invalid argument: --force requires --output <PATH>");
125 }
126
127 let runtime = StatelessRuntime;
128 let source = runtime.normalize_existing_file(&file)?;
129
130 let ignore_list = ignore_sheets.clone().unwrap_or_default();
131
132 match output {
133 None => {
134 let before_snapshot = if changed_cells {
136 Some(snapshot_cell_values(&source, &ignore_list)?)
137 } else {
138 None
139 };
140
141 let outcome = runtime.recalculate_file(&source).await?;
142
143 let summary = if changed_cells {
144 let after_snapshot = snapshot_cell_values(&source, &ignore_list)?;
145 Some(build_changed_cells_summary(
146 before_snapshot.as_ref().unwrap(),
147 &after_snapshot,
148 if ignore_list.is_empty() {
149 None
150 } else {
151 Some(ignore_list)
152 },
153 ))
154 } else {
155 None
156 };
157
158 Ok(serde_json::to_value(RecalculateResponse {
159 file: source.display().to_string(),
160 backend: outcome.backend,
161 duration_ms: outcome.duration_ms,
162 cells_evaluated: outcome.cells_evaluated,
163 eval_errors: outcome.eval_errors,
164 source_path: None,
165 target_path: None,
166 changed: None,
167 changed_cells_summary: summary,
168 })?)
169 }
170 Some(output_path) => {
171 let target = runtime.normalize_destination_path(&output_path)?;
175 ensure_output_path_is_distinct(&source, &target)?;
176
177 let target_exists = target.exists();
178 if target_exists && !force {
179 bail!(
180 "output exists: output path '{}' already exists",
181 target.display()
182 );
183 }
184
185 let target_parent = target.parent().unwrap_or_else(|| Path::new("."));
186 let temp_file = Builder::new()
187 .prefix(".recalculate-")
188 .suffix(".xlsx")
189 .tempfile_in(target_parent)
190 .map_err(|error| {
191 anyhow!(
192 "write failed: unable to create temp output in '{}': {}",
193 target_parent.display(),
194 error
195 )
196 })?;
197 let temp_path = temp_file.path().to_path_buf();
198
199 runtime.copy_file(&source, &temp_path).map_err(|error| {
200 anyhow!(
201 "write failed: unable to copy workbook from '{}' to '{}': {}",
202 source.display(),
203 target.display(),
204 error
205 )
206 })?;
207
208 let before_snapshot = if changed_cells {
210 Some(snapshot_cell_values(&temp_path, &ignore_list)?)
211 } else {
212 None
213 };
214
215 let outcome = runtime.recalculate_file(&temp_path).await?;
216
217 let summary = if changed_cells {
219 let after_snapshot = snapshot_cell_values(&temp_path, &ignore_list)?;
220 Some(build_changed_cells_summary(
221 before_snapshot.as_ref().unwrap(),
222 &after_snapshot,
223 if ignore_list.is_empty() {
224 None
225 } else {
226 Some(ignore_list)
227 },
228 ))
229 } else {
230 None
231 };
232
233 if target_exists {
234 fs::remove_file(&target).map_err(|error| {
235 anyhow!(
236 "write failed: unable to remove existing output '{}': {}",
237 target.display(),
238 error
239 )
240 })?;
241 }
242
243 temp_file.persist(&target).map_err(|error| {
244 anyhow!(
245 "write failed: unable to persist recalculated output to '{}': {}",
246 target.display(),
247 error.error
248 )
249 })?;
250
251 Ok(serde_json::to_value(RecalculateResponse {
252 file: target.display().to_string(),
253 backend: outcome.backend,
254 duration_ms: outcome.duration_ms,
255 cells_evaluated: outcome.cells_evaluated,
256 eval_errors: outcome.eval_errors,
257 source_path: Some(source.display().to_string()),
258 target_path: Some(target.display().to_string()),
259 changed: Some(true),
260 changed_cells_summary: summary,
261 })?)
262 }
263 }
264}
265
266fn ensure_output_path_is_distinct(source: &Path, output: &Path) -> Result<()> {
267 let source_identity = canonical_identity_path(source)?;
268 let output_identity = canonical_identity_path(output)?;
269 if source_identity == output_identity {
270 bail!("invalid argument: --output path resolves to the same file as input");
271 }
272 Ok(())
273}
274
275fn canonical_identity_path(path: &Path) -> Result<PathBuf> {
276 if path.exists() {
277 return fs::canonicalize(path).map_err(|e| {
278 anyhow!(
279 "failed to resolve canonical identity path for '{}': {}",
280 path.display(),
281 e
282 )
283 });
284 }
285
286 let parent = path.parent().unwrap_or_else(|| Path::new("."));
287 let name = path
288 .file_name()
289 .ok_or_else(|| anyhow!("invalid argument: output path must include a file name"))?;
290
291 let parent_canonical = fs::canonicalize(parent).map_err(|_| {
292 anyhow!(
293 "invalid argument: output parent directory '{}' does not exist or is inaccessible",
294 parent.display()
295 )
296 })?;
297
298 Ok(parent_canonical.join(name))
299}