formualizer_eval/engine/ingest.rs
1use super::Engine;
2use crate::SheetId;
3use crate::traits::EvaluationContext;
4use formualizer_common::{ExcelError, ExcelErrorKind};
5
6/// Trait implemented by data sources that can stream workbook contents into an Engine.
7/// This lives in formualizer-eval so IO backends can depend on it without creating cycles.
8pub trait EngineLoadStream<R>
9where
10 R: EvaluationContext,
11{
12 type Error;
13 fn stream_into_engine(&mut self, engine: &mut Engine<R>) -> Result<(), Self::Error>;
14}
15
16impl<R> Engine<R>
17where
18 R: EvaluationContext,
19{
20 /// Register the sheets of a file being loaded, folding the engine's seeded
21 /// default sheet into the file's first sheet when that is safe.
22 ///
23 /// This is the single entry point every [`EngineLoadStream`] implementation
24 /// must use instead of looping over `add_sheet`. A freshly constructed
25 /// [`Engine`] is seeded with one default sheet (`Sheet1` unless configured
26 /// otherwise). Appending the file's sheets next to that seed leaves a
27 /// phantom sheet that does not exist in the file and shifts every
28 /// `SHEET()`/`SHEETS()` result and every [`SheetId`] by one (issue #332).
29 ///
30 /// Behaviour:
31 ///
32 /// * **Duplicate names are rejected.** Sheet names are unique and
33 /// case-insensitive in Excel; `add_sheet` is idempotent, so two file
34 /// sheets sharing a name would silently merge and the second sheet's
35 /// cells would overwrite the first's. That is silent data loss, so it is
36 /// an error instead.
37 /// * **The default sheet is only folded into a fresh engine.** If the
38 /// engine already holds user content the default sheet is left exactly
39 /// as-is and the file's sheets are added alongside it. Renaming a
40 /// populated sheet would hand the user's sheet to the file, letting the
41 /// file's cells overwrite it and rewriting formulas that mention it;
42 /// existing data is always preserved instead.
43 /// * The fold is a rename, so the default [`SheetId`] and the sheet's
44 /// position in the Arrow store are reused by the file's first sheet and
45 /// the remaining sheets keep the file's order.
46 ///
47 /// Returns the [`SheetId`] of each name, in the order given.
48 pub fn adopt_file_sheets<'a, I>(&mut self, names: I) -> Result<Vec<SheetId>, ExcelError>
49 where
50 I: IntoIterator<Item = &'a str>,
51 {
52 let names: Vec<&str> = names.into_iter().collect();
53
54 // Reject duplicates before mutating anything so a malformed file cannot
55 // leave the engine half-registered.
56 let mut seen: std::collections::HashMap<String, &str> =
57 std::collections::HashMap::with_capacity(names.len());
58 for name in &names {
59 if let Some(previous) = seen.insert(name.to_lowercase(), name) {
60 return Err(ExcelError::new(ExcelErrorKind::Value).with_message(format!(
61 "Duplicate sheet name in workbook: '{name}' collides with '{previous}' \
62 (sheet names are case-insensitive)"
63 )));
64 }
65 }
66
67 if let Some(first) = names.first()
68 && *first != self.default_sheet_name()
69 && self.is_fresh_for_load()
70 {
71 let default_id = self.default_sheet_id();
72 self.rename_sheet(default_id, first)?;
73 }
74
75 let mut ids = Vec::with_capacity(names.len());
76 for name in &names {
77 ids.push(self.add_sheet(name)?);
78 }
79 Ok(ids)
80 }
81
82 /// True when the engine still looks exactly as [`Engine::new`] left it: one
83 /// sheet, which is the default sheet, with no cells, no values, no named
84 /// ranges and no row-visibility state.
85 ///
86 /// Only in that state can the default sheet be renamed without taking
87 /// something away from the caller.
88 fn is_fresh_for_load(&self) -> bool {
89 let default_id = self.default_sheet_id();
90 let sheets = self.graph.sheet_reg().all_sheets();
91 if sheets.len() != 1 || sheets[0].0 != default_id {
92 return false;
93 }
94 if self.graph.vertices_in_sheet(default_id).next().is_some() {
95 return false;
96 }
97 if self.graph.named_ranges_iter().next().is_some()
98 || self.graph.sheet_named_ranges_iter().next().is_some()
99 {
100 return false;
101 }
102 if self.has_row_visibility_state() || self.has_staged_formulas() {
103 return false;
104 }
105 match self.sheet_store().sheets.len() {
106 0 => true,
107 1 => {
108 let sheet = &self.sheet_store().sheets[0];
109 sheet.nrows == 0 && sheet.columns.is_empty()
110 }
111 _ => false,
112 }
113 }
114}