Add CSV import functionality

- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support

All 201 tests pass.
This commit is contained in:
Edward Langley
2026-04-01 01:32:19 -07:00
parent 2cf1123bcb
commit 23e26f0e06
6 changed files with 256 additions and 38 deletions

View File

@ -163,50 +163,69 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
path,
model_name,
array_path,
} => import_json_headless(model, path, model_name.as_deref(), array_path.as_deref()),
} => import_headless(model, path, model_name.as_deref(), array_path.as_deref()),
}
}
fn import_json_headless(
fn import_headless(
model: &mut Model,
path: &str,
model_name: Option<&str>,
array_path: Option<&str>,
) -> CommandResult {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
};
let is_csv = path.ends_with(".csv");
let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => return CommandResult::err(format!("No array at path '{ap}'")),
let records = if is_csv {
// Parse CSV file
match crate::import::csv_parser::parse_csv(path) {
Ok(recs) => recs,
Err(e) => return CommandResult::err(e.to_string()),
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
// Find first array
let paths = crate::import::analyzer::find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
// Parse JSON file
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
};
if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => return CommandResult::err("Could not extract records array"),
None => return CommandResult::err(format!("No array at path '{ap}'")),
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
return CommandResult::err("No array found in JSON");
let paths = crate::import::analyzer::find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => return CommandResult::err("Could not extract records array"),
}
} else {
return CommandResult::err("No array found in JSON");
}
}
};
let proposals = analyze_records(&records);
// Auto-accept all and build via ImportPipeline
// Build via ImportPipeline
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
// For JSON, we need the original parsed value
// Re-read and parse to get it (or pass it up from above)
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = crate::import::wizard::ImportPipeline {
raw: value,
raw,
array_paths: vec![],
selected_path: array_path.unwrap_or("").to_string(),
records,
@ -223,7 +242,7 @@ fn import_json_headless(
match pipeline.build_model() {
Ok(new_model) => {
*model = new_model;
CommandResult::ok_msg("JSON imported successfully")
CommandResult::ok_msg("Imported successfully")
}
Err(e) => CommandResult::err(e.to_string()),
}