Files
improvise/src/import/wizard.rs
Edward Langley 183b2350f7 chore: reformat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:07:22 -07:00

414 lines
14 KiB
Rust

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<String>,
pub selected_path: String,
pub records: Vec<Value>,
pub proposals: Vec<FieldProposal>,
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::<Vec<_>>().join(", "))
.unwrap_or_default()
),
Value::Object(map) => format!(
"Object with {} top-level keys: {}",
map.len(),
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
),
_ => "Unknown JSON structure".to_string(),
}
}
/// Build a Model from the current proposals. Pure — no side effects.
pub fn build_model(&self) -> Result<Model> {
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<String>,
}
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<Model> {
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");
}
}