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 self.ensure_not_poisoned()?;
53 let names: Vec<&str> = names.into_iter().collect();
54
55 // Reject duplicates before mutating anything so a malformed file cannot
56 // leave the engine half-registered.
57 let mut seen: std::collections::HashMap<String, &str> =
58 std::collections::HashMap::with_capacity(names.len());
59 for name in &names {
60 if let Some(previous) = seen.insert(name.to_lowercase(), name) {
61 return Err(ExcelError::new(ExcelErrorKind::Value).with_message(format!(
62 "Duplicate sheet name in workbook: '{name}' collides with '{previous}' \
63 (sheet names are case-insensitive)"
64 )));
65 }
66 }
67
68 if let Some(first) = names.first()
69 && *first != self.default_sheet_name()
70 && self.is_fresh_for_load()
71 {
72 let default_id = self.default_sheet_id();
73 self.rename_sheet(default_id, first)?;
74 }
75
76 let mut ids = Vec::with_capacity(names.len());
77 for name in &names {
78 ids.push(self.add_sheet(name)?);
79 }
80 Ok(ids)
81 }
82
83 /// True when the engine still looks exactly as [`Engine::new`] left it: one
84 /// sheet, which is the default sheet, with no cells, no values, no named
85 /// ranges and no row-visibility state.
86 ///
87 /// Only in that state can the default sheet be renamed without taking
88 /// something away from the caller.
89 fn is_fresh_for_load(&self) -> bool {
90 let default_id = self.default_sheet_id();
91 let sheets = self.graph.sheet_reg().all_sheets();
92 if sheets.len() != 1 || sheets[0].0 != default_id {
93 return false;
94 }
95 if self
96 .graph
97 .grid_vertices_in_sheet(default_id)
98 .next()
99 .is_some()
100 {
101 return false;
102 }
103 if self.graph.named_ranges_iter().next().is_some()
104 || self.graph.sheet_named_ranges_iter().next().is_some()
105 {
106 return false;
107 }
108 if self.has_row_visibility_state() || self.has_staged_formulas() {
109 return false;
110 }
111 match self.sheet_store().sheets.len() {
112 0 => true,
113 1 => {
114 let sheet = &self.sheet_store().sheets[0];
115 sheet.nrows == 0 && sheet.columns.is_empty()
116 }
117 _ => false,
118 }
119 }
120}