Skip to main content

improv_storage_csv/
lib.rs

1//! CSV/TSV connectivity (Phase A, post-v0.5.0 plan): import a CSV/TSV file into
2//! an Improv `Model`, and export a measure's cells back to CSV/TSV (an input
3//! measure's stored cells, or a derived measure's engine-computed ones).
4//!
5//! Mirrors `improv_storage_sql`'s shape (see its module docs): a column→model
6//! mapping (dimension columns → categories/items, one value column → a
7//! measure) turns external tabular data into ordinary input cells. CSV has no
8//! live "refresh" concept (no persistent connection to re-query) — import is a
9//! one-shot static load; re-running it against a changed file re-populates the
10//! measure the same way a fresh `import_csv` call would.
11//!
12//! * **Storage separation.** CSV is a data *source/sink* only; the canonical
13//!   model lives in Mentat. Import produces ordinary categories/items/measures/
14//!   input cells — the engine gains no CSV-specific path.
15//! * **Testability.** Every file-based entry point (`import_csv`,
16//!   `export_measure_csv`) has an `io::Read`/`io::Write` sibling
17//!   (`import_csv_from`, `export_measure_csv_to`) so tests exercise the real
18//!   logic against an in-memory buffer, no tempfiles required.
19#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
20// The attribute is only used inside #[cfg(test)] modules, so non-test
21// coverage builds would otherwise warn that the feature is unused.
22#![cfg_attr(coverage_nightly, allow(unused_features))]
23
24use improv_core_model::{CategoryId, Coordinate, MeasureId, Model, Value, ValueType};
25use std::io;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, thiserror::Error)]
29pub enum CsvError {
30    #[error("io error: {0}")]
31    Io(#[from] io::Error),
32    #[error("csv error: {0}")]
33    Csv(#[from] csv::Error),
34    #[error("row {row}: {message}")]
35    Row { row: usize, message: String },
36    #[error("{0}")]
37    Other(String),
38}
39
40pub type Result<T> = std::result::Result<T, CsvError>;
41
42/// A column reference: either a header name (requires `has_header`) or a
43/// 0-based column index. Supporting both lets headerless files work too.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ColumnRef {
46    Name(String),
47    Index(usize),
48}
49
50/// A CSV/TSV column that becomes a category: its distinct values become items.
51pub struct DimensionMapping {
52    pub column: ColumnRef,
53    pub category_id: CategoryId,
54    pub category_name: String,
55}
56
57/// How to map a CSV/TSV file's columns onto model elements. Each `dimensions`
58/// entry names a column that becomes a **category**; `value_column` names the
59/// column that becomes the imported measure's value. A row therefore
60/// contributes one input cell: `measure[dim=val, …] = value_column`.
61pub struct ImportSpec {
62    /// File to read (ignored by `import_csv_from`, which takes a reader).
63    pub path: PathBuf,
64    /// Field delimiter: `b','` for CSV, `b'\t'` for TSV.
65    pub delimiter: u8,
66    /// Whether the file's first row is a header (enables `ColumnRef::Name`).
67    pub has_header: bool,
68    /// The measure to create/populate for the value column.
69    pub measure_id: MeasureId,
70    pub measure_name: String,
71    /// The declared type of the value column. `Number` parses strictly
72    /// (a non-numeric value errors); `Boolean`/`Text` never error on the value
73    /// column — a `Boolean` column that fails to match true/false falls back
74    /// to `Text` for that cell.
75    pub value_type: ValueType,
76    pub value_column: ColumnRef,
77    pub dimensions: Vec<DimensionMapping>,
78    /// Base id for minting item ids; new items get sequential ids from here.
79    pub item_id_base: u32,
80    /// Most data records accepted; more is an error. `None` means
81    /// [`DEFAULT_MAX_RECORDS`].
82    pub max_records: Option<usize>,
83}
84
85/// Record cap applied when [`ImportSpec::max_records`] is `None`.
86pub const DEFAULT_MAX_RECORDS: usize = 10_000_000;
87/// Longest field accepted on import, in bytes (1 MiB).
88pub const MAX_FIELD_BYTES: usize = 1 << 20;
89/// Longest record accepted on import, in bytes. Enforced while reading, so a
90/// file with one enormous field errors instead of being buffered whole.
91pub const MAX_RECORD_BYTES: u64 = 64 << 20;
92
93/// A reader that refuses to read more than `limit` bytes in total; the import
94/// loop advances `limit` to "end of the last record + [`MAX_RECORD_BYTES`]".
95struct Budget<R> {
96    inner: R,
97    read: u64,
98    limit: std::rc::Rc<std::cell::Cell<u64>>,
99}
100
101impl<R: io::Read> io::Read for Budget<R> {
102    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
103        let room = self.limit.get().saturating_sub(self.read);
104        if room == 0 && !buf.is_empty() {
105            return Err(io::Error::other(format!(
106                "a record exceeds {MAX_RECORD_BYTES} bytes"
107            )));
108        }
109        let take = buf.len().min(usize::try_from(room).unwrap_or(usize::MAX));
110        let n = self.inner.read(&mut buf[..take])?;
111        self.read += n as u64;
112        Ok(n)
113    }
114}
115
116/// Import a CSV/TSV file into `model` per `spec`. Returns the number of cells
117/// imported. See [`import_csv_from`] for the reader-based (testable) version.
118pub fn import_csv(model: &mut Model, spec: &ImportSpec) -> Result<usize> {
119    let file = std::fs::File::open(&spec.path)?;
120    import_csv_from(file, model, spec)
121}
122
123/// Import CSV/TSV data read from `reader` into `model` per `spec`
124/// (`spec.path` is ignored). Distinct dimension-column values become items,
125/// interned by name — an item is reused when one already exists with that
126/// name in the target category. Returns the number of cells imported.
127pub fn import_csv_from<R: io::Read>(
128    reader: R,
129    model: &mut Model,
130    spec: &ImportSpec,
131) -> Result<usize> {
132    let limit = std::rc::Rc::new(std::cell::Cell::new(MAX_RECORD_BYTES));
133    let mut rdr = csv::ReaderBuilder::new()
134        .delimiter(spec.delimiter)
135        .has_headers(spec.has_header)
136        .from_reader(Budget {
137            inner: reader,
138            read: 0,
139            limit: limit.clone(),
140        });
141    let max_records = spec.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
142
143    let headers = if spec.has_header {
144        Some(rdr.headers()?.clone())
145    } else {
146        None
147    };
148
149    // Everything below up to the "commit" comment is READ-ONLY on `model`, so
150    // any error (bad column, short row, unparsable number) returns before the
151    // caller's model is touched: import is atomic without staging a clone.
152    // Resolve column refs to indices up front; error clearly if a named
153    // column doesn't exist (or a name is used with no header row).
154    let dim_idx: Vec<(usize, CategoryId)> = spec
155        .dimensions
156        .iter()
157        .map(|d| resolve_col(headers.as_ref(), &d.column).map(|i| (i, d.category_id)))
158        .collect::<Result<_>>()?;
159    let val_idx = resolve_col(headers.as_ref(), &spec.value_column)?;
160    let max_idx = dim_idx
161        .iter()
162        .map(|(i, _)| *i)
163        .chain(std::iter::once(val_idx))
164        .max()
165        .unwrap_or(0);
166
167    // Per-category interner: item name -> ItemId. Seeded with the model's
168    // EXISTING items (by category + name) so re-import reuses items instead of
169    // minting duplicates with the same name.
170    let mut interner = improv_data_source::ItemInterner::seeded_from(model, spec.item_id_base);
171
172    let mut cells: Vec<(Coordinate, Value)> = Vec::new();
173    for result in rdr.records() {
174        let record = result?;
175        let row_no = record
176            .position()
177            .map(|p| p.line() as usize)
178            .unwrap_or(cells.len() + 1);
179        if let Some(p) = record.position() {
180            limit.set(p.byte() + record.as_slice().len() as u64 + MAX_RECORD_BYTES);
181        }
182        if cells.len() == max_records {
183            return Err(CsvError::Row {
184                row: row_no,
185                message: format!("more than {max_records} records"),
186            });
187        }
188        if record.iter().any(|f| f.len() > MAX_FIELD_BYTES) {
189            return Err(CsvError::Row {
190                row: row_no,
191                message: format!("a field exceeds {MAX_FIELD_BYTES} bytes"),
192            });
193        }
194        if record.len() <= max_idx {
195            return Err(CsvError::Row {
196                row: row_no,
197                message: format!(
198                    "expected at least {} column(s), got {}",
199                    max_idx + 1,
200                    record.len()
201                ),
202            });
203        }
204        let mut coord = Coordinate::new();
205        for (ci, cat) in &dim_idx {
206            let key = record.get(*ci).unwrap_or("");
207            let item = interner.intern(*cat, key);
208            coord = coord.with(*cat, item);
209        }
210        let raw = record.get(val_idx).unwrap_or("");
211        let value = if spec.value_type == ValueType::Number {
212            let n: f64 = raw.trim().parse().map_err(|_| CsvError::Row {
213                row: row_no,
214                message: format!("value column is not numeric: {raw:?}"),
215            })?;
216            Value::Number(n)
217        } else {
218            parse_flexible(spec.value_type, raw)
219        };
220        cells.push((coord, value));
221    }
222
223    // Commit: every row validated, so mutate the caller's model now — create
224    // the categories/measure, register interned items, set the input cells.
225    improv_data_source::ensure_categories(
226        model,
227        spec.dimensions
228            .iter()
229            .map(|d| (d.category_id, d.category_name.as_str())),
230    );
231    improv_data_source::add_input_measure(
232        model,
233        spec.measure_id,
234        &spec.measure_name,
235        spec.value_type,
236        Vec::from_iter(spec.dimensions.iter().map(|d| d.category_id)),
237        Some(format!("imported from CSV: {}", spec.path.display())),
238    );
239    interner.register(model);
240    let count = cells.len();
241    for (coord, value) in cells {
242        model.set_input(spec.measure_id, coord, value);
243    }
244    Ok(count)
245}
246
247/// Parse a value column string for a non-`Number` declared type. `Boolean`
248/// matches "true"/"false" case-insensitively; anything else (including any
249/// string under a `Text`-declared column) passes through as `Text`. Never
250/// errors — only a `Number`-declared column enforces a numeric parse.
251fn parse_flexible(value_type: ValueType, raw: &str) -> Value {
252    match value_type {
253        ValueType::Boolean => match raw.trim().to_ascii_lowercase().as_str() {
254            "true" => Value::Boolean(true),
255            "false" => Value::Boolean(false),
256            _ => Value::Text(raw.to_string()),
257        },
258        _ => Value::Text(raw.to_string()),
259    }
260}
261
262/// Resolve a `ColumnRef` to a 0-based column index.
263fn resolve_col(headers: Option<&csv::StringRecord>, col: &ColumnRef) -> Result<usize> {
264    match col {
265        ColumnRef::Index(i) => Ok(*i),
266        ColumnRef::Name(name) => {
267            let h = headers.ok_or_else(|| {
268                CsvError::Other(format!(
269                    "column '{name}' needs a header row (has_header is false)"
270                ))
271            })?;
272            h.iter()
273                .position(|c| c == name)
274                .ok_or_else(|| CsvError::Other(format!("no column named '{name}' in header")))
275        }
276    }
277}
278
279/// Write a measure's cells to a CSV/TSV file: one column per dimension
280/// category (item name) plus a value column, header row first. An **input**
281/// measure exports its stored cells; a **derived** measure is evaluated by the
282/// engine and exports its computed cells (IMPROV.txt:113 — export the computed
283/// view). Returns the number of rows written. See [`export_measure_csv_to`]
284/// for the writer-based (testable) version.
285pub fn export_measure_csv(
286    model: &Model,
287    measure: MeasureId,
288    path: &Path,
289    delimiter: u8,
290) -> Result<usize> {
291    let file = std::fs::File::create(path)?;
292    export_measure_csv_to(model, measure, file, delimiter)
293}
294
295/// Export options for [`export_measure_csv_with`].
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct ExportSpec {
298    /// Field delimiter: `b','` for CSV, `b'\t'` for TSV.
299    pub delimiter: u8,
300    /// Prefix text that a spreadsheet would run as a formula (a text cell,
301    /// item name or header starting with `=`, `+`, `-`, `@`, tab or CR) with
302    /// a `'` (the OWASP CSV-injection mitigation). Numbers are never touched.
303    /// On by default; turn off only for an exact-text round trip into Improv.
304    pub neutralize_formulas: bool,
305}
306
307impl Default for ExportSpec {
308    fn default() -> Self {
309        ExportSpec {
310            delimiter: b',',
311            neutralize_formulas: true,
312        }
313    }
314}
315
316/// `'`-prefix `s` if a spreadsheet would treat it as a formula.
317fn neutralize(s: String, on: bool) -> String {
318    if on && s.starts_with(['=', '+', '-', '@', '\t', '\r']) {
319        format!("'{s}")
320    } else {
321        s
322    }
323}
324
325/// Write a measure's cells to `writer` as CSV/TSV (`delimiter`). Rows are
326/// written in coordinate order, so output is deterministic.
327///
328/// * **Input measure** — writes `model.inputs` for that measure, unchanged.
329/// * **Derived measure** — runs `improv_engine::dataflow::evaluate` and writes
330///   the computed cells. An engine failure (cyclic/unsupported formula) is a
331///   [`CsvError::Other`], not a silently empty file.
332///
333/// Error cells (`#ERR`, and the NaN the engine produces for a domain error
334/// like division by zero) are **skipped**, matching how input `Value::Error`
335/// cells have always been treated: the value column is typed, and writing
336/// `#ERR`/`NaN` into it would make the file fail to re-import as a `Number`
337/// column. The returned count reflects rows actually written, so a caller can
338/// see that skipping happened.
339///
340/// Text that a spreadsheet would run as a formula is neutralized; see
341/// [`ExportSpec::neutralize_formulas`] and [`export_measure_csv_with`].
342pub fn export_measure_csv_to<W: io::Write>(
343    model: &Model,
344    measure: MeasureId,
345    writer: W,
346    delimiter: u8,
347) -> Result<usize> {
348    let spec = ExportSpec {
349        delimiter,
350        ..ExportSpec::default()
351    };
352    export_measure_csv_with(model, measure, writer, &spec)
353}
354
355/// [`export_measure_csv_to`] with explicit [`ExportSpec`] options.
356pub fn export_measure_csv_with<W: io::Write>(
357    model: &Model,
358    measure: MeasureId,
359    writer: W,
360    spec: &ExportSpec,
361) -> Result<usize> {
362    let guard = |s: String| neutralize(s, spec.neutralize_formulas);
363    let m = model
364        .measures
365        .get(&measure)
366        .ok_or_else(|| CsvError::Other(format!("no measure {measure:?}")))?;
367
368    let dim_cols: Vec<(CategoryId, String)> = m
369        .categories
370        .iter()
371        .map(|c| {
372            let name = model
373                .categories
374                .get(c)
375                .map(|x| x.name.0.clone())
376                .unwrap_or_else(|| format!("cat{}", c.0));
377            (*c, name)
378        })
379        .collect();
380
381    let mut wtr = csv::WriterBuilder::new()
382        .delimiter(spec.delimiter)
383        .from_writer(writer);
384    let mut header: Vec<String> = dim_cols.iter().map(|(_, n)| guard(n.clone())).collect();
385    header.push(guard(m.name.0.clone()));
386    wtr.write_record(&header)?;
387
388    // Derived measures have no stored cells; ask the engine for computed ones.
389    let mut rows: Vec<(Coordinate, String)> = if m.is_derived() {
390        let out = improv_engine::dataflow::evaluate(model, &[measure])
391            .map_err(|e| CsvError::Other(format!("evaluating measure {measure:?}: {e}")))?;
392        out.get(&measure)
393            .map(|cells| {
394                cells
395                    .iter()
396                    .filter_map(|(k, v)| {
397                        cell_text(v, spec.neutralize_formulas)
398                            .map(|s| (improv_engine::decode_coord(k), s))
399                    })
400                    .collect()
401            })
402            .unwrap_or_default()
403    } else {
404        model
405            .inputs
406            .iter()
407            .filter(|((mid, _), _)| *mid == measure)
408            .filter_map(|((_, c), v)| {
409                value_text(v, spec.neutralize_formulas).map(|s| (c.clone(), s))
410            })
411            .collect()
412    };
413    rows.sort_by(|a, b| a.0.cmp(&b.0));
414
415    let mut count = 0usize;
416    for (coord, value_str) in rows {
417        let mut row: Vec<String> = Vec::with_capacity(dim_cols.len() + 1);
418        for (cat, _) in &dim_cols {
419            let name = coord
420                .get(*cat)
421                .and_then(|i| model.items.get(&i))
422                .map(|it| guard(it.name.0.clone()))
423                .unwrap_or_default();
424            row.push(name);
425        }
426        row.push(value_str);
427        wtr.write_record(&row)?;
428        count += 1;
429    }
430    wtr.flush()?;
431    Ok(count)
432}
433
434/// The value column text for a stored input value, or `None` to skip the row
435/// (error cells have no meaningful, re-importable cell text).
436fn value_text(val: &Value, neutralize_formulas: bool) -> Option<String> {
437    match val {
438        Value::Number(n) => Some(n.to_string()),
439        Value::Boolean(b) => Some(b.to_string()),
440        Value::Text(s) => Some(neutralize(s.clone(), neutralize_formulas)),
441        Value::DateTime(dt) => Some(dt.to_rfc3339()),
442        Value::Enum(e) => Some(e.to_string()),
443        Value::Error(_) => None,
444    }
445}
446
447/// The value column text for an engine-computed cell, or `None` to skip the
448/// row. Skips `#ERR` and non-finite numbers (the engine's NaN convention for
449/// division by zero / domain errors) for the same reason `value_text` skips
450/// `Value::Error`: neither re-imports as a number.
451fn cell_text(v: &improv_engine::CellValue, neutralize_formulas: bool) -> Option<String> {
452    use improv_engine::CellValue;
453    match v {
454        CellValue::Num(bits) => {
455            let n = f64::from_bits(*bits);
456            n.is_finite().then(|| n.to_string())
457        }
458        CellValue::Err(_) => None,
459        CellValue::Text(t) => Some(neutralize(t.clone(), neutralize_formulas)),
460        other => Some(other.to_string()),
461    }
462}
463
464#[cfg(test)]
465#[cfg_attr(coverage_nightly, coverage(off))]
466mod tests {
467    use super::*;
468    use improv_core_model::ItemId;
469    use std::io::Cursor;
470
471    const SALES_CSV: &str = "time,product,revenue\n\
4722025,WidgetA,1000\n\
4732025,WidgetB,500\n\
4742026,WidgetA,1200\n";
475
476    fn revenue_spec() -> ImportSpec {
477        ImportSpec {
478            path: PathBuf::new(),
479            delimiter: b',',
480            has_header: true,
481            measure_id: MeasureId(100),
482            measure_name: "Revenue".into(),
483            value_type: ValueType::Number,
484            value_column: ColumnRef::Name("revenue".into()),
485            dimensions: vec![
486                DimensionMapping {
487                    column: ColumnRef::Name("time".into()),
488                    category_id: CategoryId(1),
489                    category_name: "Time".into(),
490                },
491                DimensionMapping {
492                    column: ColumnRef::Name("product".into()),
493                    category_id: CategoryId(2),
494                    category_name: "Product".into(),
495                },
496            ],
497            item_id_base: 1000,
498            max_records: None,
499        }
500    }
501
502    fn item_id(model: &Model, cat: CategoryId, name: &str) -> ItemId {
503        model
504            .items
505            .values()
506            .find(|i| i.category == cat && i.name.0 == name)
507            .unwrap()
508            .id
509    }
510
511    #[test]
512    fn import_maps_columns_to_model() {
513        let mut model = Model::new();
514        let n = import_csv_from(Cursor::new(SALES_CSV), &mut model, &revenue_spec()).unwrap();
515        assert_eq!(n, 3, "three rows imported");
516
517        assert_eq!(model.category_by_name("Time").unwrap().items.len(), 2);
518        assert_eq!(model.category_by_name("Product").unwrap().items.len(), 2);
519
520        let m = model.measure_by_name("Revenue").unwrap();
521        assert!(m.is_input());
522        assert_eq!(m.categories.len(), 2);
523        assert_eq!(model.inputs.len(), 3);
524
525        let time = model.category_by_name("Time").unwrap().id;
526        let product = model.category_by_name("Product").unwrap().id;
527        let coord = Coordinate::from_pairs([
528            (time, item_id(&model, time, "2025")),
529            (product, item_id(&model, product, "WidgetA")),
530        ]);
531        assert_eq!(
532            model.input(MeasureId(100), &coord),
533            Some(&Value::Number(1000.0))
534        );
535        let coord2 = Coordinate::from_pairs([
536            (time, item_id(&model, time, "2025")),
537            (product, item_id(&model, product, "WidgetB")),
538        ]);
539        assert_eq!(
540            model.input(MeasureId(100), &coord2),
541            Some(&Value::Number(500.0))
542        );
543    }
544
545    #[test]
546    fn import_then_export_round_trips_into_fresh_model() {
547        let mut model = Model::new();
548        import_csv_from(Cursor::new(SALES_CSV), &mut model, &revenue_spec()).unwrap();
549
550        let mut buf: Vec<u8> = Vec::new();
551        let n = export_measure_csv_to(&model, MeasureId(100), &mut buf, b',').unwrap();
552        assert_eq!(n, 3);
553
554        // The exported header uses the model's category/measure names
555        // ("Time", "Product", "Revenue"), not the original CSV's lowercase
556        // column names, so the re-import spec matches those.
557        let mut reimport_spec = revenue_spec();
558        reimport_spec.value_column = ColumnRef::Name("Revenue".into());
559        reimport_spec.dimensions[0].column = ColumnRef::Name("Time".into());
560        reimport_spec.dimensions[1].column = ColumnRef::Name("Product".into());
561
562        let mut model2 = Model::new();
563        let n2 = import_csv_from(Cursor::new(buf), &mut model2, &reimport_spec).unwrap();
564        assert_eq!(n2, 3);
565
566        // Same multiset of values in both models.
567        let vals = |m: &Model| {
568            let mut v: Vec<f64> = m.inputs.values().filter_map(|x| x.as_number()).collect();
569            v.sort_by(|a, b| a.partial_cmp(b).unwrap());
570            v
571        };
572        assert_eq!(vals(&model), vals(&model2));
573        assert_eq!(model.inputs.len(), model2.inputs.len());
574    }
575
576    #[test]
577    fn tsv_import_works_identically() {
578        let tsv = "time\tproduct\trevenue\n2025\tWidgetA\t1000\n2026\tWidgetA\t1200\n";
579        let mut spec = revenue_spec();
580        spec.delimiter = b'\t';
581        let mut model = Model::new();
582        let n = import_csv_from(Cursor::new(tsv), &mut model, &spec).unwrap();
583        assert_eq!(n, 2);
584        assert_eq!(model.category_by_name("Time").unwrap().items.len(), 2);
585    }
586
587    #[test]
588    fn malformed_row_errors_with_row_number() {
589        let bad = "time,product,revenue\n2025,WidgetA,1000\n2026,OnlyOneField\n";
590        let mut model = Model::new();
591        let err = import_csv_from(Cursor::new(bad), &mut model, &revenue_spec()).unwrap_err();
592        let msg = err.to_string();
593        // The csv crate's own record-length check fires first (with line info).
594        assert!(msg.contains('3') || msg.contains("line"), "got: {msg}");
595    }
596
597    #[test]
598    fn short_row_within_declared_width_errors_with_row_number() {
599        // 3-column header, but the value column is requested at an index
600        // beyond a well-formed-but-narrow row (flexible-length off by design,
601        // but exercise the explicit bounds check too via column indices).
602        let mut spec = revenue_spec();
603        spec.has_header = false;
604        spec.dimensions[0].column = ColumnRef::Index(0);
605        spec.dimensions[1].column = ColumnRef::Index(1);
606        spec.value_column = ColumnRef::Index(5); // out of range for a 3-col row
607        let mut model = Model::new();
608        let err =
609            import_csv_from(Cursor::new("2025,WidgetA,1000\n"), &mut model, &spec).unwrap_err();
610        match err {
611            CsvError::Row { row, message } => {
612                assert_eq!(row, 1);
613                assert!(message.contains("column"), "got: {message}");
614            }
615            other => panic!("expected a Row error, got {other:?}"),
616        }
617    }
618
619    #[test]
620    fn non_numeric_value_errors_for_number_type() {
621        let bad = "time,product,revenue\n2025,WidgetA,notanumber\n";
622        let mut model = Model::new();
623        let err = import_csv_from(Cursor::new(bad), &mut model, &revenue_spec()).unwrap_err();
624        match err {
625            CsvError::Row { row, message } => {
626                assert_eq!(row, 2);
627                assert!(message.contains("numeric"), "got: {message}");
628            }
629            other => panic!("expected a Row error, got {other:?}"),
630        }
631    }
632
633    #[test]
634    fn boolean_and_text_columns_round_trip() {
635        let csv_text = "product,active\nWidgetA,true\nWidgetB,FALSE\n";
636        let mut spec = revenue_spec();
637        spec.value_type = ValueType::Boolean;
638        spec.value_column = ColumnRef::Name("active".into());
639        spec.dimensions = vec![DimensionMapping {
640            column: ColumnRef::Name("product".into()),
641            category_id: CategoryId(2),
642            category_name: "Product".into(),
643        }];
644        spec.measure_name = "Active".into();
645
646        let mut model = Model::new();
647        let n = import_csv_from(Cursor::new(csv_text), &mut model, &spec).unwrap();
648        assert_eq!(n, 2);
649        let product = model.category_by_name("Product").unwrap().id;
650        let coord_a = Coordinate::from_pairs([(product, item_id(&model, product, "WidgetA"))]);
651        let coord_b = Coordinate::from_pairs([(product, item_id(&model, product, "WidgetB"))]);
652        assert_eq!(
653            model.input(MeasureId(100), &coord_a),
654            Some(&Value::Boolean(true))
655        );
656        assert_eq!(
657            model.input(MeasureId(100), &coord_b),
658            Some(&Value::Boolean(false))
659        );
660
661        // Export + re-import round-trips the booleans as "true"/"false".
662        let mut buf = Vec::new();
663        export_measure_csv_to(&model, MeasureId(100), &mut buf, b',').unwrap();
664        let text = String::from_utf8(buf).unwrap();
665        assert!(text.contains("true"));
666        assert!(text.contains("false"));
667
668        // A Text-typed column passes values through verbatim, never erroring.
669        let mut tspec = spec;
670        tspec.value_type = ValueType::Text;
671        tspec.measure_id = MeasureId(101);
672        tspec.measure_name = "Note".into();
673        let text_csv = "product,active\nWidgetA,hello world\nWidgetB,42\n";
674        let mut model2 = Model::new();
675        import_csv_from(Cursor::new(text_csv), &mut model2, &tspec).unwrap();
676        let coord_a2 = Coordinate::from_pairs([(product, item_id(&model2, product, "WidgetA"))]);
677        assert_eq!(
678            model2.input(MeasureId(101), &coord_a2),
679            Some(&Value::Text("hello world".to_string()))
680        );
681    }
682
683    /// Data rows of an export, parsed by a real CSV reader (fields can hold
684    /// quoted tabs/CRs).
685    fn csv_rows(buf: &[u8]) -> Vec<Vec<String>> {
686        csv::Reader::from_reader(buf)
687            .records()
688            .map(|r| r.unwrap().iter().map(str::to_string).collect())
689            .collect()
690    }
691
692    /// A Text measure over a Product category, for the injection tests.
693    fn text_model(product: &str, note: &str) -> Model {
694        let mut spec = revenue_spec();
695        spec.value_type = ValueType::Text;
696        spec.value_column = ColumnRef::Index(1);
697        spec.has_header = false;
698        spec.dimensions = vec![DimensionMapping {
699            column: ColumnRef::Index(0),
700            category_id: CategoryId(2),
701            category_name: "Product".into(),
702        }];
703        let mut w = csv::Writer::from_writer(Vec::new());
704        w.write_record([product, note]).unwrap();
705        let data = w.into_inner().unwrap();
706        let mut model = Model::new();
707        import_csv_from(Cursor::new(data), &mut model, &spec).unwrap();
708        model
709    }
710
711    #[test]
712    fn export_neutralizes_formula_text_by_default() {
713        for lead in ["=", "+", "-", "@", "\t", "\r"] {
714            let payload = format!("{lead}cmd|' /C calc'!A0");
715            let model = text_model(&payload, &payload);
716            let mut buf = Vec::new();
717            export_measure_csv_to(&model, MeasureId(100), &mut buf, b',').unwrap();
718            let rows = csv_rows(&buf);
719            let quoted = format!("'{payload}");
720            assert_eq!(rows, vec![vec![quoted.clone(), quoted]], "{lead:?}");
721        }
722        // Harmless text is left alone.
723        let model = text_model("Widget", "fine = ok");
724        let mut buf = Vec::new();
725        export_measure_csv_to(&model, MeasureId(100), &mut buf, b',').unwrap();
726        assert_eq!(csv_rows(&buf), vec![vec!["Widget", "fine = ok"]]);
727    }
728
729    #[test]
730    fn export_leaves_negative_numbers_alone() {
731        let mut model = Model::new();
732        import_csv_from(
733            Cursor::new("time,product,revenue\n2025,WidgetA,-5\n"),
734            &mut model,
735            &revenue_spec(),
736        )
737        .unwrap();
738        let mut buf = Vec::new();
739        export_measure_csv_to(&model, MeasureId(100), &mut buf, b',').unwrap();
740        assert_eq!(csv_rows(&buf), vec![vec!["2025", "WidgetA", "-5"]]);
741    }
742
743    #[test]
744    fn export_round_trip_can_opt_out_of_neutralizing() {
745        let model = text_model("-East", "=A1+1");
746        let spec = ExportSpec {
747            neutralize_formulas: false,
748            ..ExportSpec::default()
749        };
750        let mut buf = Vec::new();
751        export_measure_csv_with(&model, MeasureId(100), &mut buf, &spec).unwrap();
752        assert_eq!(csv_rows(&buf), vec![vec!["-East", "=A1+1"]]);
753    }
754
755    #[test]
756    fn import_caps_record_count() {
757        let mut spec = revenue_spec();
758        spec.max_records = Some(2);
759        let mut model = Model::new();
760        let err = import_csv_from(Cursor::new(SALES_CSV), &mut model, &spec).unwrap_err();
761        assert!(err.to_string().contains("more than 2 records"), "{err}");
762        assert!(model.inputs.is_empty(), "atomic: nothing imported");
763        spec.max_records = Some(3);
764        assert_eq!(
765            import_csv_from(Cursor::new(SALES_CSV), &mut model, &spec).unwrap(),
766            3
767        );
768    }
769
770    #[test]
771    fn import_caps_field_length() {
772        let ok = "x".repeat(MAX_FIELD_BYTES);
773        let csv_ok = format!("time,product,revenue\n2025,{ok},1\n");
774        let mut model = Model::new();
775        assert_eq!(
776            import_csv_from(Cursor::new(csv_ok), &mut model, &revenue_spec()).unwrap(),
777            1
778        );
779        let big = "x".repeat(MAX_FIELD_BYTES + 1);
780        let csv_big = format!("time,product,revenue\n2025,{big},1\n");
781        let err =
782            import_csv_from(Cursor::new(csv_big), &mut Model::new(), &revenue_spec()).unwrap_err();
783        assert!(err.to_string().contains("row 2: a field exceeds"), "{err}");
784    }
785
786    #[test]
787    fn import_refuses_a_huge_record_while_streaming() {
788        // An unterminated quote would otherwise swallow the whole input into
789        // one field; the reader budget stops it at MAX_RECORD_BYTES.
790        let header = "time,product,revenue\n2025,A,1\n2025,\"";
791        let reader = io::Read::chain(
792            Cursor::new(header),
793            io::Read::take(io::repeat(b'x'), MAX_RECORD_BYTES * 4),
794        );
795        let err = import_csv_from(reader, &mut Model::new(), &revenue_spec()).unwrap_err();
796        assert!(err.to_string().contains("exceeds"), "{err}");
797    }
798
799    #[test]
800    fn missing_column_errors_clearly() {
801        let mut model = Model::new();
802        let mut spec = revenue_spec();
803        spec.value_column = ColumnRef::Name("nope".into());
804        assert!(import_csv_from(Cursor::new(SALES_CSV), &mut model, &spec).is_err());
805    }
806
807    #[test]
808    fn repeat_import_with_same_base_never_aliases_item_ids() {
809        let mut model = Model::new();
810        import_csv_from(
811            Cursor::new("time,product,revenue\n2025,WidgetA,1000\n"),
812            &mut model,
813            &revenue_spec(),
814        )
815        .unwrap();
816        let time = model.category_by_name("Time").unwrap().id;
817        let product = model.category_by_name("Product").unwrap().id;
818        let first = model.clone();
819
820        // Same fixed base (what the GUI wizard always passes), different data.
821        let n = import_csv_from(
822            Cursor::new("time,product,revenue\n2026,WidgetB,1200\n"),
823            &mut model,
824            &revenue_spec(),
825        )
826        .unwrap();
827        assert_eq!(n, 1);
828
829        // Four distinct items, each id owning exactly one name.
830        let mut seen: Vec<(CategoryId, ItemId, &str)> = model
831            .items
832            .values()
833            .map(|i| (i.category, i.id, i.name.0.as_str()))
834            .collect();
835        seen.sort();
836        assert_eq!(seen.len(), 4, "2025/2026 + WidgetA/WidgetB: {seen:?}");
837        let ids: std::collections::HashSet<ItemId> = seen.iter().map(|(_, id, _)| *id).collect();
838        assert_eq!(ids.len(), 4, "no id reused for two names: {seen:?}");
839
840        // The first import's items kept their names, and its cell survived.
841        for it in first.items.values() {
842            assert_eq!(model.items.get(&it.id).unwrap().name.0, it.name.0);
843        }
844        let old = Coordinate::from_pairs([
845            (time, item_id(&model, time, "2025")),
846            (product, item_id(&model, product, "WidgetA")),
847        ]);
848        assert_eq!(
849            model.input(MeasureId(100), &old),
850            Some(&Value::Number(1000.0))
851        );
852        let fresh = Coordinate::from_pairs([
853            (time, item_id(&model, time, "2026")),
854            (product, item_id(&model, product, "WidgetB")),
855        ]);
856        assert_eq!(
857            model.input(MeasureId(100), &fresh),
858            Some(&Value::Number(1200.0))
859        );
860    }
861
862    #[test]
863    fn failed_import_leaves_the_model_untouched() {
864        let cases = [
865            "time,product,revenue\n2026,OnlyOneField\n", // short row
866            "time,product,revenue\n2025,WidgetA,notanumber\n", // unparsable number
867            "time,product,nope\n2025,WidgetA,1000\n",    // unknown value column
868        ];
869        for bad in cases {
870            let mut model = Model::new();
871            let before = model.clone();
872            assert!(import_csv_from(Cursor::new(bad), &mut model, &revenue_spec()).is_err());
873            assert_eq!(model, before, "empty model mutated by: {bad:?}");
874
875            // Same, onto a model that already holds a good import.
876            let mut model = Model::new();
877            import_csv_from(Cursor::new(SALES_CSV), &mut model, &revenue_spec()).unwrap();
878            let before = model.clone();
879            assert!(import_csv_from(Cursor::new(bad), &mut model, &revenue_spec()).is_err());
880            assert_eq!(model, before, "populated model mutated by: {bad:?}");
881        }
882    }
883
884    #[test]
885    fn failed_import_does_not_overwrite_an_existing_measure() {
886        use improv_core_model::{Measure, MeasureKind, Name};
887
888        // A pre-existing measure sharing the spec's measure id, with different
889        // metadata: a failed import must not clobber it.
890        let mut model = Model::new();
891        model.add_category(CategoryId(9), "Region");
892        model.add_item(ItemId(90), CategoryId(9), "East");
893        model.add_measure(Measure {
894            id: MeasureId(100),
895            name: Name("Headcount".into()),
896            value_type: ValueType::Text,
897            categories: vec![CategoryId(9)],
898            kind: MeasureKind::Input,
899            description: None,
900        });
901        let coord = Coordinate::from_pairs([(CategoryId(9), ItemId(90))]);
902        model.set_input(MeasureId(100), coord.clone(), Value::Text("hi".into()));
903        let before = model.clone();
904
905        assert!(import_csv_from(
906            Cursor::new("time,product,revenue\n2025,WidgetA,notanumber\n"),
907            &mut model,
908            &revenue_spec(),
909        )
910        .is_err());
911        assert_eq!(model, before);
912        let m = model.measures.get(&MeasureId(100)).unwrap();
913        assert_eq!(m.name.0, "Headcount");
914        assert_eq!(m.value_type, ValueType::Text);
915        assert_eq!(m.categories, vec![CategoryId(9)]);
916        assert_eq!(
917            model.input(MeasureId(100), &coord),
918            Some(&Value::Text("hi".into()))
919        );
920    }
921
922    #[test]
923    fn export_missing_measure_errors() {
924        let model = Model::new();
925        let mut buf = Vec::new();
926        assert!(export_measure_csv_to(&model, MeasureId(999), &mut buf, b',').is_err());
927    }
928
929    // ---- derived-measure export (the computed view, IMPROV.txt:113) ----
930
931    /// The canonical Time x Product revenue oracle: Price[Product] x
932    /// Quantity[Time,Product] -> Revenue[Time,Product], matching the engine's
933    /// own `revenue_model` fixture (crates/engine/src/dataflow.rs).
934    /// Oracle: [2025,A]=1000, [2025,B]=1000, [2026,A]=1200, [2026,B]=1600.
935    fn revenue_model() -> Model {
936        use improv_core_model::{
937            BinaryOp, DimensionSpec, Expr, Formula, Measure, MeasureKind, Name,
938        };
939
940        let (time, product) = (CategoryId(1), CategoryId(2));
941        let mut m = Model::new();
942        m.add_category(time, "Time");
943        m.add_category(product, "Product");
944        m.add_item(ItemId(10), time, "2025");
945        m.add_item(ItemId(11), time, "2026");
946        m.add_item(ItemId(20), product, "WidgetA");
947        m.add_item(ItemId(21), product, "WidgetB");
948
949        let input = |id: u32, name: &str, cats: Vec<CategoryId>| Measure {
950            id: MeasureId(id),
951            name: Name(name.into()),
952            value_type: ValueType::Number,
953            categories: cats,
954            kind: MeasureKind::Input,
955            description: None,
956        };
957        m.add_measure(input(100, "Price", vec![product]));
958        m.add_measure(input(101, "Quantity", vec![time, product]));
959        m.add_measure(Measure {
960            id: MeasureId(102),
961            name: Name("Revenue".into()),
962            value_type: ValueType::Number,
963            categories: vec![time, product],
964            kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
965                BinaryOp::Mul,
966                Box::new(Expr::Ref(MeasureId(100), DimensionSpec::default())),
967                Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
968            ))),
969            description: None,
970        });
971
972        let at = |pairs: &[(CategoryId, ItemId)]| Coordinate::from_pairs(pairs.iter().copied());
973        m.set_input(
974            MeasureId(100),
975            at(&[(product, ItemId(20))]),
976            Value::Number(10.0),
977        );
978        m.set_input(
979            MeasureId(100),
980            at(&[(product, ItemId(21))]),
981            Value::Number(20.0),
982        );
983        for (t, p, q) in [
984            (ItemId(10), ItemId(20), 100.0),
985            (ItemId(10), ItemId(21), 50.0),
986            (ItemId(11), ItemId(20), 120.0),
987            (ItemId(11), ItemId(21), 80.0),
988        ] {
989            m.set_input(
990                MeasureId(101),
991                at(&[(time, t), (product, p)]),
992                Value::Number(q),
993            );
994        }
995        m
996    }
997
998    /// Parse exported CSV into `(dim values..., value)` rows, header dropped.
999    fn parse_rows(buf: &[u8]) -> (Vec<String>, Vec<Vec<String>>) {
1000        let text = String::from_utf8(buf.to_vec()).unwrap();
1001        let mut lines = text
1002            .lines()
1003            .map(|l| l.split(',').map(|s| s.to_string()).collect::<Vec<String>>());
1004        let header = lines.next().expect("header row");
1005        (header, lines.collect())
1006    }
1007
1008    #[test]
1009    fn export_derived_measure_writes_computed_oracle_values() {
1010        let model = revenue_model();
1011        let mut buf = Vec::new();
1012        let n = export_measure_csv_to(&model, MeasureId(102), &mut buf, b',').unwrap();
1013        assert_eq!(n, 4, "four computed Revenue cells, not zero");
1014
1015        let (header, rows) = parse_rows(&buf);
1016        assert_eq!(header, vec!["Time", "Product", "Revenue"]);
1017        // Coordinate order: (2025,A), (2025,B), (2026,A), (2026,B).
1018        assert_eq!(
1019            rows,
1020            vec![
1021                vec!["2025", "WidgetA", "1000"],
1022                vec!["2025", "WidgetB", "1000"],
1023                vec!["2026", "WidgetA", "1200"],
1024                vec!["2026", "WidgetB", "1600"],
1025            ]
1026        );
1027    }
1028
1029    #[test]
1030    fn export_input_measure_is_unchanged_by_derived_support() {
1031        // Quantity is an input measure in the same model: its export must be
1032        // exactly its stored cells, untouched by the engine path.
1033        let model = revenue_model();
1034        let mut buf = Vec::new();
1035        let n = export_measure_csv_to(&model, MeasureId(101), &mut buf, b',').unwrap();
1036        assert_eq!(n, 4);
1037        let (header, rows) = parse_rows(&buf);
1038        assert_eq!(header, vec!["Time", "Product", "Quantity"]);
1039        assert_eq!(
1040            rows,
1041            vec![
1042                vec!["2025", "WidgetA", "100"],
1043                vec!["2025", "WidgetB", "50"],
1044                vec!["2026", "WidgetA", "120"],
1045                vec!["2026", "WidgetB", "80"],
1046            ]
1047        );
1048
1049        // A one-dimensional input measure still exports its single dim column.
1050        let mut buf = Vec::new();
1051        assert_eq!(
1052            export_measure_csv_to(&model, MeasureId(100), &mut buf, b',').unwrap(),
1053            2
1054        );
1055        let (header, rows) = parse_rows(&buf);
1056        assert_eq!(header, vec!["Product", "Price"]);
1057        assert_eq!(rows, vec![vec!["WidgetA", "10"], vec!["WidgetB", "20"]]);
1058    }
1059
1060    #[test]
1061    fn derived_export_reimports_as_an_input_measure_with_matching_values() {
1062        let model = revenue_model();
1063        let mut buf = Vec::new();
1064        export_measure_csv_to(&model, MeasureId(102), &mut buf, b',').unwrap();
1065
1066        let mut spec = revenue_spec();
1067        spec.measure_id = MeasureId(200);
1068        spec.measure_name = "RevenueSnapshot".into();
1069        spec.value_column = ColumnRef::Name("Revenue".into());
1070        spec.dimensions[0].column = ColumnRef::Name("Time".into());
1071        spec.dimensions[1].column = ColumnRef::Name("Product".into());
1072
1073        let mut model2 = Model::new();
1074        let n = import_csv_from(Cursor::new(buf), &mut model2, &spec).unwrap();
1075        assert_eq!(n, 4);
1076
1077        // Every computed cell round-tripped to the same value, cell by cell.
1078        let computed = improv_engine::dataflow::evaluate(&model, &[MeasureId(102)]).unwrap();
1079        let (t2, p2) = (
1080            model2.category_by_name("Time").unwrap().id,
1081            model2.category_by_name("Product").unwrap().id,
1082        );
1083        for (k, v) in computed.get(&MeasureId(102)).unwrap() {
1084            // Re-map the original coordinate onto model2's interned item ids.
1085            let orig = improv_engine::decode_coord(k);
1086            let name = |cat: CategoryId| model.items[&orig.get(cat).unwrap()].name.0.clone();
1087            let coord = Coordinate::from_pairs([
1088                (t2, item_id(&model2, t2, &name(CategoryId(1)))),
1089                (p2, item_id(&model2, p2, &name(CategoryId(2)))),
1090            ]);
1091            assert_eq!(
1092                model2.input(MeasureId(200), &coord),
1093                Some(&Value::Number(v.as_num().unwrap())),
1094                "round-trip mismatch at {orig:?}"
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn derived_export_skips_error_cells_and_still_writes_the_good_ones() {
1101        use improv_core_model::{
1102            BinaryOp, DimensionSpec, Expr, Formula, Measure, MeasureKind, Name,
1103        };
1104
1105        // Margin = Revenue / Quantity, with Quantity zeroed for [2025,WidgetA]:
1106        // the engine yields NaN there (its div-by-zero convention), which is not
1107        // re-importable as a number, so that row is skipped and the other three
1108        // are written.
1109        let mut model = revenue_model();
1110        let (time, product) = (CategoryId(1), CategoryId(2));
1111        model.set_input(
1112            MeasureId(101),
1113            Coordinate::from_pairs([(time, ItemId(10)), (product, ItemId(20))]),
1114            Value::Number(0.0),
1115        );
1116        model.add_measure(Measure {
1117            id: MeasureId(103),
1118            name: Name("Margin".into()),
1119            value_type: ValueType::Number,
1120            categories: vec![time, product],
1121            kind: MeasureKind::Derived(Formula::new(Expr::BinaryOp(
1122                BinaryOp::Div,
1123                Box::new(Expr::Ref(MeasureId(102), DimensionSpec::default())),
1124                Box::new(Expr::Ref(MeasureId(101), DimensionSpec::default())),
1125            ))),
1126            description: None,
1127        });
1128
1129        let mut buf = Vec::new();
1130        let n = export_measure_csv_to(&model, MeasureId(103), &mut buf, b',').unwrap();
1131        assert_eq!(n, 3, "the NaN/#ERR cell is skipped, the rest are written");
1132        let (_, rows) = parse_rows(&buf);
1133        assert_eq!(rows.len(), 3);
1134        let text = String::from_utf8(buf).unwrap();
1135        assert!(!text.contains("NaN"), "no NaN in the value column: {text}");
1136        assert!(
1137            !text.contains("#ERR"),
1138            "no #ERR in the value column: {text}"
1139        );
1140        // The skipped row's coordinate is absent; the others are present.
1141        assert!(!rows.iter().any(|r| r[0] == "2025" && r[1] == "WidgetA"));
1142        assert!(rows.iter().any(|r| r[0] == "2026" && r[1] == "WidgetB"));
1143
1144        // An input Value::Error cell is skipped the same way (unchanged behavior).
1145        let mut model = revenue_model();
1146        model.set_input(
1147            MeasureId(101),
1148            Coordinate::from_pairs([(time, ItemId(10)), (product, ItemId(20))]),
1149            Value::Error(improv_core_model::ValueError::new(
1150                improv_core_model::ValueErrorKind::DivisionByZero,
1151            )),
1152        );
1153        let mut buf = Vec::new();
1154        assert_eq!(
1155            export_measure_csv_to(&model, MeasureId(101), &mut buf, b',').unwrap(),
1156            3
1157        );
1158    }
1159
1160    /// An exhausted budget still allows a zero-length read (no error), but a
1161    /// real read is refused.
1162    #[test]
1163    fn exhausted_budget_allows_only_empty_reads() {
1164        use std::io::Read;
1165        let mut b = Budget {
1166            inner: Cursor::new(b"abc".to_vec()),
1167            read: 0,
1168            limit: std::rc::Rc::new(std::cell::Cell::new(0)),
1169        };
1170        assert_eq!(b.read(&mut []).unwrap(), 0);
1171        assert!(b.read(&mut [0u8; 4]).is_err());
1172    }
1173}