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        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}