use anyhow::{anyhow, Result}; use serde_json::Value; use super::analyzer::{ analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal, }; use crate::model::cell::{CellKey, CellValue}; use crate::model::Model; // ── Pipeline (no UI state) ──────────────────────────────────────────────────── /// Pure data + logic for turning a JSON value into a Model. /// No cursor, no display messages — those live in [`ImportWizard`]. #[derive(Debug)] pub struct ImportPipeline { pub raw: Value, pub array_paths: Vec, pub selected_path: String, pub records: Vec, pub proposals: Vec, pub model_name: String, } impl ImportPipeline { pub fn new(raw: Value) -> Self { let array_paths = find_array_paths(&raw); let mut pipeline = Self { raw: raw.clone(), array_paths, selected_path: String::new(), records: vec![], proposals: vec![], model_name: "Imported Model".to_string(), }; // Auto-select if root is an array or there is exactly one candidate path. if raw.is_array() { pipeline.select_path(""); } else if pipeline.array_paths.len() == 1 { let path = pipeline.array_paths[0].clone(); pipeline.select_path(&path); } pipeline } pub fn select_path(&mut self, path: &str) { self.selected_path = path.to_string(); if let Some(arr) = extract_array_at_path(&self.raw, path) { self.records = arr.clone(); self.proposals = analyze_records(&self.records); } } pub fn needs_path_selection(&self) -> bool { self.records.is_empty() && self.raw.is_object() && self.array_paths.len() != 1 } pub fn preview_summary(&self) -> String { match &self.raw { Value::Array(arr) => format!( "Array of {} records. Sample keys: {}", arr.len(), arr.first() .and_then(|r| r.as_object()) .map(|m| m.keys().take(5).cloned().collect::>().join(", ")) .unwrap_or_default() ), Value::Object(map) => format!( "Object with {} top-level keys: {}", map.len(), map.keys().take(10).cloned().collect::>().join(", ") ), _ => "Unknown JSON structure".to_string(), } } /// Build a Model from the current proposals. Pure — no side effects. pub fn build_model(&self) -> Result { let categories: Vec<&FieldProposal> = self .proposals .iter() .filter(|p| { p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory) }) .collect(); let measures: Vec<&FieldProposal> = self .proposals .iter() .filter(|p| p.accepted && p.kind == FieldKind::Measure) .collect(); if categories.is_empty() { return Err(anyhow!("At least one category must be accepted")); } let mut model = Model::new(&self.model_name); for cat_proposal in &categories { model.add_category(&cat_proposal.field)?; if let Some(cat) = model.category_mut(&cat_proposal.field) { for val in &cat_proposal.distinct_values { cat.add_item(val); } } } if !measures.is_empty() { model.add_category("Measure")?; if let Some(cat) = model.category_mut("Measure") { for m in &measures { cat.add_item(&m.field); } } } for record in &self.records { if let Value::Object(map) = record { let mut coords: Vec<(String, String)> = vec![]; let mut valid = true; for cat_proposal in &categories { let val = map .get(&cat_proposal.field) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string())); if let Some(v) = val { if let Some(cat) = model.category_mut(&cat_proposal.field) { cat.add_item(&v); } coords.push((cat_proposal.field.clone(), v)); } else { valid = false; break; } } if !valid { continue; } for measure in &measures { if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { let mut cell_coords = coords.clone(); cell_coords.push(("Measure".to_string(), measure.field.clone())); model.set_cell(CellKey::new(cell_coords), CellValue::Number(val)); } } } } Ok(model) } } // ── Wizard (UI state wrapped around the pipeline) ───────────────────────────── #[derive(Debug, Clone, PartialEq)] pub enum WizardStep { Preview, SelectArrayPath, ReviewProposals, NameModel, Done, } /// Interactive state layered on top of [`ImportPipeline`] for the TUI wizard. /// The pipeline holds all data; the wizard holds only what the UI needs to /// drive the multi-step interaction (current step, list cursor, error message). #[derive(Debug)] pub struct ImportWizard { pub pipeline: ImportPipeline, pub step: WizardStep, /// Cursor within the current list (array paths or proposals). pub cursor: usize, /// One-line message to display at the bottom of the wizard panel. pub message: Option, } impl ImportWizard { pub fn new(raw: Value) -> Self { let pipeline = ImportPipeline::new(raw); let step = if pipeline.needs_path_selection() { WizardStep::SelectArrayPath } else if pipeline.records.is_empty() { WizardStep::Preview } else { WizardStep::ReviewProposals }; Self { pipeline, step, cursor: 0, message: None, } } // ── Step transitions ────────────────────────────────────────────────────── pub fn advance(&mut self) { self.step = match self.step { WizardStep::Preview => { if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() { WizardStep::SelectArrayPath } else { WizardStep::ReviewProposals } } WizardStep::SelectArrayPath => WizardStep::ReviewProposals, WizardStep::ReviewProposals => WizardStep::NameModel, WizardStep::NameModel => WizardStep::Done, WizardStep::Done => WizardStep::Done, }; self.cursor = 0; self.message = None; } pub fn confirm_path(&mut self) { if self.cursor < self.pipeline.array_paths.len() { let path = self.pipeline.array_paths[self.cursor].clone(); self.pipeline.select_path(&path); self.advance(); } } // ── Cursor movement ─────────────────────────────────────────────────────── pub fn move_cursor(&mut self, delta: i32) { let len = match self.step { WizardStep::SelectArrayPath => self.pipeline.array_paths.len(), WizardStep::ReviewProposals => self.pipeline.proposals.len(), _ => 0, }; if len == 0 { return; } if delta > 0 { self.cursor = (self.cursor + 1).min(len - 1); } else if self.cursor > 0 { self.cursor -= 1; } } // ── Proposal editing (delegates to pipeline) ────────────────────────────── pub fn toggle_proposal(&mut self) { if self.cursor < self.pipeline.proposals.len() { self.pipeline.proposals[self.cursor].accepted = !self.pipeline.proposals[self.cursor].accepted; } } pub fn cycle_proposal_kind(&mut self) { if self.cursor < self.pipeline.proposals.len() { let p = &mut self.pipeline.proposals[self.cursor]; p.kind = match p.kind { FieldKind::Category => FieldKind::Measure, FieldKind::Measure => FieldKind::TimeCategory, FieldKind::TimeCategory => FieldKind::Label, FieldKind::Label => FieldKind::Category, }; } } // ── Model name input ────────────────────────────────────────────────────── pub fn push_name_char(&mut self, c: char) { self.pipeline.model_name.push(c); } pub fn pop_name_char(&mut self) { self.pipeline.model_name.pop(); } // ── Delegate build to pipeline ──────────────────────────────────────────── pub fn build_model(&self) -> Result { self.pipeline.build_model() } } #[cfg(test)] mod tests { use super::ImportPipeline; use crate::import::analyzer::FieldKind; use serde_json::json; #[test] fn flat_array_auto_selected() { let raw = json!([ {"region": "East", "product": "Shirts", "revenue": 100.0}, {"region": "West", "product": "Shirts", "revenue": 200.0}, ]); let p = ImportPipeline::new(raw); assert!(!p.records.is_empty()); assert!(!p.proposals.is_empty()); assert!(!p.needs_path_selection()); } #[test] fn numeric_field_proposed_as_measure() { let raw = json!([{"region": "East", "revenue": 100.0, "cost": 50.0}]); let p = ImportPipeline::new(raw); let revenue = p.proposals.iter().find(|p| p.field == "revenue").unwrap(); assert_eq!(revenue.kind, FieldKind::Measure); } #[test] fn low_cardinality_string_field_proposed_as_category() { let raw = json!([ {"region": "East", "revenue": 100.0}, {"region": "West", "revenue": 200.0}, {"region": "East", "revenue": 150.0}, ]); let p = ImportPipeline::new(raw); let region = p.proposals.iter().find(|p| p.field == "region").unwrap(); assert_eq!(region.kind, FieldKind::Category); } #[test] fn nested_json_needs_path_selection_when_multiple_arrays() { let raw = json!({ "orders": [{"id": 1}, {"id": 2}], "products": [{"name": "A"}, {"name": "B"}], }); let p = ImportPipeline::new(raw); assert!(p.needs_path_selection()); } #[test] fn single_nested_array_auto_selected() { let raw = json!({ "data": [ {"region": "East", "revenue": 100.0}, {"region": "West", "revenue": 200.0}, ] }); let p = ImportPipeline::new(raw); assert!(!p.records.is_empty()); assert!(!p.needs_path_selection()); } #[test] fn select_path_populates_records_and_proposals() { let raw = json!({ "a": [{"x": 1}, {"x": 2}], "b": [{"y": "foo"}, {"y": "bar"}], }); let mut p = ImportPipeline::new(raw); p.select_path("b"); assert!(!p.records.is_empty()); assert!(!p.proposals.is_empty()); } #[test] fn build_model_fails_with_no_accepted_categories() { let raw = json!([{"revenue": 100.0, "cost": 50.0}]); let mut p = ImportPipeline::new(raw); for prop in &mut p.proposals { prop.accepted = false; } assert!(p.build_model().is_err()); } #[test] fn build_model_creates_categories_and_measure_category() { let raw = json!([ {"region": "East", "revenue": 100.0}, {"region": "West", "revenue": 200.0}, ]); let p = ImportPipeline::new(raw); let model = p.build_model().unwrap(); assert!(model.category("region").is_some()); assert!(model.category("Measure").is_some()); } #[test] fn build_model_cells_match_source_data() { let raw = json!([ {"region": "East", "revenue": 100.0}, {"region": "West", "revenue": 200.0}, ]); let p = ImportPipeline::new(raw); let model = p.build_model().unwrap(); use crate::model::cell::CellKey; let k_east = CellKey::new(vec![ ("Measure".to_string(), "revenue".to_string()), ("region".to_string(), "East".to_string()), ]); let k_west = CellKey::new(vec![ ("Measure".to_string(), "revenue".to_string()), ("region".to_string(), "West".to_string()), ]); assert_eq!( model.get_cell(&k_east).and_then(|v| v.as_f64()), Some(100.0) ); assert_eq!( model.get_cell(&k_west).and_then(|v| v.as_f64()), Some(200.0) ); } #[test] fn model_name_defaults_to_imported_model() { let raw = json!([{"x": 1.0}]); let p = ImportPipeline::new(raw); assert_eq!(p.model_name, "Imported Model"); } }