1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
20#![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#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ColumnRef {
46 Name(String),
47 Index(usize),
48}
49
50pub struct DimensionMapping {
52 pub column: ColumnRef,
53 pub category_id: CategoryId,
54 pub category_name: String,
55}
56
57pub struct ImportSpec {
62 pub path: PathBuf,
64 pub delimiter: u8,
66 pub has_header: bool,
68 pub measure_id: MeasureId,
70 pub measure_name: String,
71 pub value_type: ValueType,
76 pub value_column: ColumnRef,
77 pub dimensions: Vec<DimensionMapping>,
78 pub item_id_base: u32,
80 pub max_records: Option<usize>,
83}
84
85pub const DEFAULT_MAX_RECORDS: usize = 10_000_000;
87pub const MAX_FIELD_BYTES: usize = 1 << 20;
89pub const MAX_RECORD_BYTES: u64 = 64 << 20;
92
93struct 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
116pub 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
123pub 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 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 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 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
247fn 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
262fn 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
279pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct ExportSpec {
298 pub delimiter: u8,
300 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
316fn neutralize(s: String, on: bool) -> String {
318 if on && s.starts_with(['=', '+', '-', '@', '\t', '\r']) {
319 format!("'{s}")
320 } else {
321 s
322 }
323}
324
325pub 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
355pub 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 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
434fn 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
447fn 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 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 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 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 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); 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 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 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 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 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 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 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 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 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 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", "time,product,revenue\n2025,WidgetA,notanumber\n", "time,product,nope\n2025,WidgetA,1000\n", ];
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 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 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 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 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 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 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 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 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 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 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 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 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 #[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}