1use std::path::PathBuf;
14
15use improv_core_model::{CategoryId, MeasureId, ValueType};
16use improv_storage_csv::{ColumnRef, DimensionMapping, ImportSpec};
17
18const ITEM_ID_BASE: u32 = 1_000_000;
21
22#[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#[derive(Debug, Clone, PartialEq)]
35pub struct ImportForm {
36 pub path: String,
37 pub tsv: bool,
39 pub has_header: bool,
40 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#[derive(Debug, Clone, Default, PartialEq)]
63pub struct ExportForm {
64 pub measure_id: Option<MeasureId>,
65 pub path: String,
66 pub tsv: bool,
67}
68
69pub 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
79fn 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
91pub 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; }
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
157pub 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 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'); 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, 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 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 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 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 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 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 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 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 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 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}