Skip to main content

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}