Skip to main content

improv_gui/
csv_wizard.rs

1//! CSV/TSV import/export wizard: form state + pure spec-building for the GUI
2//! panel (rendered by `ImprovApp::csv_import_ui`/`csv_export_ui` in `app.rs`).
3//! Mirrors the CLI's `import-csv`/`export-csv` semantics (delimiter
4//! auto-detected from the `.tsv` extension unless overridden; `item_id_base`
5//! set high enough to avoid colliding with hand-assigned ids — see
6//! `crates/cli/src/main.rs::cmd_import_csv`/`cmd_export_csv`) so the wizard
7//! behaves exactly like `improv import-csv`/`export-csv`.
8//!
9//! Kept free of any `egui::Ui` parameter so `build_import_spec`/
10//! `build_export_args`/`detect_delimiter` are unit-tested without a window;
11//! the actual widgets live in `app.rs`, which owns `ImprovApp`'s state.
12
13use std::path::PathBuf;
14
15use improv_core_model::{CategoryId, MeasureId, ValueType};
16use improv_storage_csv::{ColumnRef, DimensionMapping, ImportSpec};
17
18/// Base id for minting item ids on import — high enough to avoid colliding
19/// with hand-assigned ids (matches the CLI's `cmd_import_csv`).
20const ITEM_ID_BASE: u32 = 1_000_000;
21
22/// One dimension-mapping row in the import form: a CSV/TSV column, and the
23/// category it becomes (id + name). All three fields are free text, parsed on
24/// submit by `build_import_spec`.
25#[derive(Debug, Clone, Default, PartialEq)]
26pub struct DimRow {
27    pub column: String,
28    pub category_id: String,
29    pub category_name: String,
30}
31
32/// The import wizard's editable form state. Mirrors the CLI's `import-csv`
33/// arguments (see `crates/cli/src/main.rs::cmd_import_csv`).
34#[derive(Debug, Clone, PartialEq)]
35pub struct ImportForm {
36    pub path: String,
37    /// Force TSV even if the path doesn't end in `.tsv` (checkbox).
38    pub tsv: bool,
39    pub has_header: bool,
40    /// Blank = auto-assign the next free measure id.
41    pub measure_id: String,
42    pub measure_name: String,
43    pub value_column: String,
44    pub dimensions: Vec<DimRow>,
45}
46
47impl Default for ImportForm {
48    fn default() -> Self {
49        ImportForm {
50            path: String::new(),
51            tsv: false,
52            has_header: true,
53            measure_id: String::new(),
54            measure_name: String::new(),
55            value_column: String::new(),
56            dimensions: vec![DimRow::default()],
57        }
58    }
59}
60
61/// The export wizard's form state.
62#[derive(Debug, Clone, Default, PartialEq)]
63pub struct ExportForm {
64    pub measure_id: Option<MeasureId>,
65    pub path: String,
66    pub tsv: bool,
67}
68
69/// The delimiter for `path`: TSV if `tsv_checked` or the path ends in `.tsv`,
70/// else CSV. Matches the CLI's `--tsv`/extension convention exactly.
71pub fn detect_delimiter(path: &str, tsv_checked: bool) -> u8 {
72    if tsv_checked || path.ends_with(".tsv") {
73        b'\t'
74    } else {
75        b','
76    }
77}
78
79/// A column reference: a 0-based index when there's no header row, else a
80/// header name (mirrors the CLI's `col_ref` closure).
81fn column_ref(s: &str, has_header: bool) -> ColumnRef {
82    if has_header {
83        ColumnRef::Name(s.to_string())
84    } else {
85        s.parse::<usize>()
86            .map(ColumnRef::Index)
87            .unwrap_or_else(|_| ColumnRef::Name(s.to_string()))
88    }
89}
90
91/// Build an `ImportSpec` from the wizard's form fields. Validates as the CLI
92/// does: a non-empty path, a numeric measure id, a non-empty measure name and
93/// value column, and at least one fully-filled dimension row (a blank spacer
94/// row — all three fields empty — is skipped, not an error). Pure: no egui, no
95/// I/O; `improv_storage_csv::import_csv` does the real work.
96pub fn build_import_spec(form: &ImportForm) -> Result<ImportSpec, String> {
97    let path_str = form.path.trim();
98    if path_str.is_empty() {
99        return Err("file path is required".into());
100    }
101    let measure_id = form
102        .measure_id
103        .trim()
104        .parse::<u32>()
105        .map_err(|_| format!("measure id must be a number, got '{}'", form.measure_id))?;
106    let measure_name = form.measure_name.trim();
107    if measure_name.is_empty() {
108        return Err("measure name is required".into());
109    }
110    let value_col = form.value_column.trim();
111    if value_col.is_empty() {
112        return Err("value column is required".into());
113    }
114
115    let mut dimensions = Vec::new();
116    for row in &form.dimensions {
117        let (col, cid, cname) = (
118            row.column.trim(),
119            row.category_id.trim(),
120            row.category_name.trim(),
121        );
122        if col.is_empty() && cid.is_empty() && cname.is_empty() {
123            continue; // blank spacer row
124        }
125        if col.is_empty() || cid.is_empty() || cname.is_empty() {
126            return Err(
127                "each dimension row needs a column, a category id, and a category name".into(),
128            );
129        }
130        let category_id = cid
131            .parse::<u32>()
132            .map_err(|_| format!("category id must be a number, got '{cid}'"))?;
133        dimensions.push(DimensionMapping {
134            column: column_ref(col, form.has_header),
135            category_id: CategoryId(category_id),
136            category_name: cname.to_string(),
137        });
138    }
139    if dimensions.is_empty() {
140        return Err("at least one dimension mapping is required".into());
141    }
142
143    Ok(ImportSpec {
144        path: PathBuf::from(path_str),
145        delimiter: detect_delimiter(path_str, form.tsv),
146        has_header: form.has_header,
147        measure_id: MeasureId(measure_id),
148        measure_name: measure_name.to_string(),
149        value_type: ValueType::Number,
150        value_column: column_ref(value_col, form.has_header),
151        dimensions,
152        item_id_base: ITEM_ID_BASE,
153        max_records: None,
154    })
155}
156
157/// Resolve the export form into `(measure, path, delimiter)`. Pure: no egui,
158/// no I/O; `improv_storage_csv::export_measure_csv` does the real work.
159pub fn build_export_args(form: &ExportForm) -> Result<(MeasureId, PathBuf, u8), String> {
160    let measure = form.measure_id.ok_or("select a measure to export")?;
161    let path_str = form.path.trim();
162    if path_str.is_empty() {
163        return Err("target file path is required".into());
164    }
165    Ok((
166        measure,
167        PathBuf::from(path_str),
168        detect_delimiter(path_str, form.tsv),
169    ))
170}
171
172#[cfg(test)]
173#[cfg_attr(coverage_nightly, coverage(off))]
174mod tests {
175    use super::*;
176    use improv_core_model::{Coordinate, ItemId, Measure, MeasureKind, Model, Name, Value};
177    use improv_storage_csv::export_measure_csv;
178
179    /// A unique path under the OS temp dir; removed by `Drop`.
180    struct TempFile(PathBuf);
181    impl TempFile {
182        fn new(suffix: &str) -> Self {
183            let p = std::env::temp_dir().join(format!(
184                "improv_gui_csv_wizard_test_{}_{}{}",
185                std::process::id(),
186                std::time::SystemTime::now()
187                    .duration_since(std::time::UNIX_EPOCH)
188                    .unwrap()
189                    .as_nanos(),
190                suffix
191            ));
192            TempFile(p)
193        }
194        fn path_str(&self) -> String {
195            self.0.to_string_lossy().into_owned()
196        }
197    }
198    impl Drop for TempFile {
199        fn drop(&mut self) {
200            let _ = std::fs::remove_file(&self.0);
201        }
202    }
203
204    fn dim(col: &str, id: &str, name: &str) -> DimRow {
205        DimRow {
206            column: col.into(),
207            category_id: id.into(),
208            category_name: name.into(),
209        }
210    }
211
212    #[test]
213    fn detect_delimiter_from_extension() {
214        assert_eq!(detect_delimiter("sales.csv", false), b',');
215        assert_eq!(detect_delimiter("sales.tsv", false), b'\t');
216        assert_eq!(detect_delimiter("sales.csv", true), b'\t'); // checkbox overrides
217        assert_eq!(detect_delimiter("no_extension", false), b',');
218    }
219
220    #[test]
221    fn build_import_spec_end_to_end_via_real_tempfile() {
222        let tmp = TempFile::new(".csv");
223        std::fs::write(
224            &tmp.0,
225            "time,product,revenue\n2025,WidgetA,1000\n2025,WidgetB,500\n2026,WidgetA,1200\n",
226        )
227        .unwrap();
228
229        let form = ImportForm {
230            path: tmp.path_str(),
231            tsv: false,
232            has_header: true,
233            measure_id: "100".into(),
234            measure_name: "Revenue".into(),
235            value_column: "revenue".into(),
236            dimensions: vec![dim("time", "1", "Time"), dim("product", "2", "Product")],
237        };
238        let spec = build_import_spec(&form).expect("valid spec");
239        assert_eq!(spec.delimiter, b',');
240
241        let mut model = Model::new();
242        let n = improv_storage_csv::import_csv(&mut model, &spec).expect("import");
243        assert_eq!(n, 3);
244        assert_eq!(model.category_by_name("Time").unwrap().items.len(), 2);
245        assert_eq!(model.category_by_name("Product").unwrap().items.len(), 2);
246        let m = model.measure_by_name("Revenue").unwrap();
247        assert!(m.is_input());
248        assert_eq!(model.inputs.len(), 3);
249    }
250
251    #[test]
252    fn tsv_extension_is_auto_detected() {
253        let tmp = TempFile::new(".tsv");
254        std::fs::write(&tmp.0, "time\tproduct\trevenue\n2025\tWidgetA\t1000\n").unwrap();
255
256        let form = ImportForm {
257            path: tmp.path_str(),
258            tsv: false, // no explicit override — extension alone should pick TSV
259            has_header: true,
260            measure_id: "1".into(),
261            measure_name: "Revenue".into(),
262            value_column: "revenue".into(),
263            dimensions: vec![dim("time", "1", "Time"), dim("product", "2", "Product")],
264        };
265        let spec = build_import_spec(&form).expect("valid spec");
266        assert_eq!(spec.delimiter, b'\t');
267
268        let mut model = Model::new();
269        let n = improv_storage_csv::import_csv(&mut model, &spec).expect("import");
270        assert_eq!(n, 1);
271    }
272
273    #[test]
274    fn export_then_reimport_round_trips() {
275        let export_tmp = TempFile::new(".csv");
276
277        // Build a small model with an input cell directly (bypassing the form).
278        let mut model = Model::new();
279        let (t, p) = (CategoryId(1), CategoryId(2));
280        model.add_category(t, "Time");
281        model.add_category(p, "Product");
282        model.add_item(ItemId(10), t, "2025");
283        model.add_item(ItemId(20), p, "WidgetA");
284        model.add_measure(Measure {
285            id: MeasureId(100),
286            name: Name("Revenue".into()),
287            value_type: ValueType::Number,
288            categories: vec![t, p],
289            kind: MeasureKind::Input,
290            description: None,
291        });
292        model.set_input(
293            MeasureId(100),
294            Coordinate::from_pairs([(t, ItemId(10)), (p, ItemId(20))]),
295            Value::Number(42.0),
296        );
297
298        let export_form = ExportForm {
299            measure_id: Some(MeasureId(100)),
300            path: export_tmp.path_str(),
301            tsv: false,
302        };
303        let (measure, path, delimiter) = build_export_args(&export_form).expect("valid export");
304        let exported = export_measure_csv(&model, measure, &path, delimiter).expect("export");
305        assert_eq!(exported, 1);
306
307        // Re-import into a fresh model using the exported header names.
308        let import_form = ImportForm {
309            path: export_tmp.path_str(),
310            tsv: false,
311            has_header: true,
312            measure_id: "200".into(),
313            measure_name: "Revenue2".into(),
314            value_column: "Revenue".into(),
315            dimensions: vec![dim("Time", "1", "Time"), dim("Product", "2", "Product")],
316        };
317        let spec = build_import_spec(&import_form).expect("valid spec");
318        let mut model2 = Model::new();
319        let n = improv_storage_csv::import_csv(&mut model2, &spec).expect("reimport");
320        assert_eq!(n, 1);
321        let vals: Vec<f64> = model2
322            .inputs
323            .values()
324            .filter_map(|v| v.as_number())
325            .collect();
326        assert_eq!(vals, vec![42.0]);
327    }
328
329    #[test]
330    fn malformed_spec_is_err_not_panic() {
331        // Missing dimensions (a blank row is skipped, leaving none).
332        let no_dims = ImportForm {
333            path: "whatever.csv".into(),
334            measure_id: "1".into(),
335            measure_name: "M".into(),
336            value_column: "v".into(),
337            ..ImportForm::default()
338        };
339        assert!(build_import_spec(&no_dims).is_err());
340
341        // Empty path.
342        let no_path = ImportForm {
343            measure_id: "1".into(),
344            measure_name: "M".into(),
345            value_column: "v".into(),
346            dimensions: vec![dim("a", "1", "A")],
347            ..ImportForm::default()
348        };
349        assert!(build_import_spec(&no_path).is_err());
350
351        // Non-numeric measure id.
352        let bad_id = ImportForm {
353            path: "x.csv".into(),
354            measure_id: "notanumber".into(),
355            measure_name: "M".into(),
356            value_column: "v".into(),
357            dimensions: vec![dim("a", "1", "A")],
358            ..ImportForm::default()
359        };
360        assert!(build_import_spec(&bad_id).is_err());
361
362        // Partial dimension row (column set, category id/name blank).
363        let partial_dim = ImportForm {
364            path: "x.csv".into(),
365            measure_id: "1".into(),
366            measure_name: "M".into(),
367            value_column: "v".into(),
368            dimensions: vec![dim("a", "", "")],
369            ..ImportForm::default()
370        };
371        assert!(build_import_spec(&partial_dim).is_err());
372
373        // A well-formed spec against a nonexistent file: `import_csv` errors
374        // (io::Error), never panics.
375        let good_spec_bad_file = ImportForm {
376            path: "/nonexistent/path/does/not/exist.csv".into(),
377            measure_id: "1".into(),
378            measure_name: "M".into(),
379            value_column: "v".into(),
380            dimensions: vec![dim("a", "1", "A")],
381            ..ImportForm::default()
382        };
383        let spec = build_import_spec(&good_spec_bad_file).expect("spec itself is well-formed");
384        let mut model = Model::new();
385        assert!(improv_storage_csv::import_csv(&mut model, &spec).is_err());
386    }
387
388    #[test]
389    fn each_missing_field_is_named_and_headerless_columns_are_indices() {
390        let ok = ImportForm {
391            path: "x.csv".into(),
392            has_header: false,
393            measure_id: "1".into(),
394            measure_name: "M".into(),
395            value_column: "2".into(),
396            dimensions: vec![dim("0", "1", "A"), dim("name", "2", "B")],
397            ..ImportForm::default()
398        };
399        let spec = build_import_spec(&ok).expect("valid");
400        assert_eq!(spec.value_column, ColumnRef::Index(2));
401        assert_eq!(spec.dimensions[0].column, ColumnRef::Index(0));
402        // A non-numeric reference without a header row is still a name.
403        assert_eq!(spec.dimensions[1].column, ColumnRef::Name("name".into()));
404
405        let err = |f: ImportForm| build_import_spec(&f).err().expect("rejected");
406        let with = |edit: fn(&mut ImportForm)| {
407            let mut f = ok.clone();
408            edit(&mut f);
409            f
410        };
411        assert!(err(with(|f| f.measure_name = " ".into())).contains("measure name"));
412        assert!(err(with(|f| f.value_column.clear())).contains("value column"));
413        // Any one of the three dimension fields blank (the others set) is a
414        // partial row, not a skipped spacer.
415        for d in [
416            dim("", "1", "A"),
417            dim("c", "", "A"),
418            dim("c", "1", ""),
419            dim("", "", "A"),
420        ] {
421            let mut f = ok.clone();
422            f.dimensions = vec![d];
423            assert!(err(f).contains("each dimension row"));
424        }
425        assert!(err(with(|f| f.dimensions = vec![dim("c", "x", "A")])).contains("category id"));
426    }
427
428    #[test]
429    fn export_args_need_a_measure_and_a_path() {
430        let mut f = ExportForm::default();
431        assert!(build_export_args(&f).unwrap_err().contains("measure"));
432        f.measure_id = Some(MeasureId(7));
433        f.path = "  ".into();
434        assert!(build_export_args(&f).unwrap_err().contains("path"));
435        f.path = "out.tsv".into();
436        let (m, p, d) = build_export_args(&f).unwrap();
437        assert_eq!((m, p, d), (MeasureId(7), PathBuf::from("out.tsv"), b'\t'));
438    }
439}