Skip to main content

spreadsheet_kit/cli/commands/
recalc.rs

1use 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    /// Sample of changed cells (max 50)
34    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
45/// Snapshot cell values from a workbook file, skipping sheets in `ignore`.
46fn 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
74/// Compare before/after snapshots and produce a `ChangedCellsSummary`.
75fn 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    // Collect all keys from both maps.
85    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            // In-place mode (existing behavior)
135            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            // Output mode: copy source to a temp file in the target directory,
172            // recalculate temp, then atomically move into place. This keeps an
173            // existing target untouched if recalc fails.
174            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            // Snapshot before recalc (from the copy, which has the same values as source).
209            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            // Snapshot after recalc (from the recalculated temp file).
218            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}