feat: multiple-CSV import
This commit is contained in:
@ -56,6 +56,28 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
|||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse multiple CSV files and merge into a single JSON array.
|
||||||
|
/// Each record gets a "File" field set to the filename stem (e.g., "sales" from "sales.csv").
|
||||||
|
pub fn merge_csvs(paths: &[impl AsRef<Path>]) -> Result<Vec<Value>> {
|
||||||
|
let mut all_records = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
let records = parse_csv(path)?;
|
||||||
|
for mut record in records {
|
||||||
|
if let Value::Object(ref mut map) = record {
|
||||||
|
map.insert("File".to_string(), Value::String(stem.clone()));
|
||||||
|
}
|
||||||
|
all_records.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(all_records)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_csv_field(field: &str) -> Value {
|
fn parse_csv_field(field: &str) -> Value {
|
||||||
if field.is_empty() {
|
if field.is_empty() {
|
||||||
return Value::Null;
|
return Value::Null;
|
||||||
@ -158,6 +180,40 @@ mod tests {
|
|||||||
assert_eq!(records[0]["Active"], Value::String("true".to_string()));
|
assert_eq!(records[0]["Active"], Value::String("true".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_csvs_adds_file_field_from_stem() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let sales = dir.path().join("sales.csv");
|
||||||
|
let expenses = dir.path().join("expenses.csv");
|
||||||
|
fs::write(&sales, "Region,Revenue\nEast,100\nWest,200").unwrap();
|
||||||
|
fs::write(&expenses, "Region,Revenue\nEast,50\nWest,75").unwrap();
|
||||||
|
|
||||||
|
let records = merge_csvs(&[sales, expenses]).unwrap();
|
||||||
|
assert_eq!(records.len(), 4);
|
||||||
|
assert_eq!(records[0]["File"], Value::String("sales".to_string()));
|
||||||
|
assert_eq!(records[1]["File"], Value::String("sales".to_string()));
|
||||||
|
assert_eq!(records[2]["File"], Value::String("expenses".to_string()));
|
||||||
|
assert_eq!(records[3]["File"], Value::String("expenses".to_string()));
|
||||||
|
// Original fields preserved
|
||||||
|
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
records[2]["Revenue"],
|
||||||
|
Value::Number(serde_json::Number::from(50))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_csvs_single_file_works() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("data.csv");
|
||||||
|
fs::write(&path, "Name,Value\nA,1").unwrap();
|
||||||
|
|
||||||
|
let records = merge_csvs(&[path]).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0]["File"], Value::String("data".to_string()));
|
||||||
|
assert_eq!(records[0]["Name"], Value::String("A".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_checking_csv_format() {
|
fn parse_checking_csv_format() {
|
||||||
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
|
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
|
||||||
|
|||||||
78
src/main.rs
78
src/main.rs
@ -29,7 +29,7 @@ trait Runnable {
|
|||||||
|
|
||||||
struct CmdLineArgs {
|
struct CmdLineArgs {
|
||||||
file_path: Option<PathBuf>,
|
file_path: Option<PathBuf>,
|
||||||
import_path: Option<PathBuf>,
|
import_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runnable for CmdLineArgs {
|
impl Runnable for CmdLineArgs {
|
||||||
@ -38,36 +38,55 @@ impl Runnable for CmdLineArgs {
|
|||||||
let model = get_initial_model(&self.file_path)?;
|
let model = get_initial_model(&self.file_path)?;
|
||||||
|
|
||||||
// Pre-TUI import: parse JSON or CSV and open wizard
|
// Pre-TUI import: parse JSON or CSV and open wizard
|
||||||
let import_value = self.import_path.and_then(get_import_data);
|
let import_value = if self.import_paths.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
get_import_data(&self.import_paths)
|
||||||
|
};
|
||||||
|
|
||||||
run_tui(model, self.file_path, import_value)
|
run_tui(model, self.file_path, import_value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_import_data(path: PathBuf) -> Option<Value> {
|
fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
|
||||||
match std::fs::read_to_string(&path) {
|
let all_csv = paths.iter().all(|p| csv_path_p(p));
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Cannot read '{}': {e}", path.display());
|
if paths.len() > 1 {
|
||||||
None
|
if !all_csv {
|
||||||
|
eprintln!("Multi-file import only supports CSV files");
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
Ok(content) => {
|
match crate::import::csv_parser::merge_csvs(paths) {
|
||||||
if csv_path_p(&path) {
|
Ok(records) => Some(Value::Array(records)),
|
||||||
// Parse CSV and wrap as JSON array
|
Err(e) => {
|
||||||
match crate::import::csv_parser::parse_csv(&path) {
|
eprintln!("CSV merge error: {e}");
|
||||||
Ok(records) => Some(serde_json::Value::Array(records)),
|
None
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("CSV parse error: {e}");
|
}
|
||||||
None
|
} else {
|
||||||
|
let path = &paths[0];
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Cannot read '{}': {e}", path.display());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(content) => {
|
||||||
|
if csv_path_p(path) {
|
||||||
|
match crate::import::csv_parser::parse_csv(path) {
|
||||||
|
Ok(records) => Some(Value::Array(records)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("CSV parse error: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
match serde_json::from_str::<Value>(&content) {
|
||||||
// Parse JSON
|
Err(e) => {
|
||||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
eprintln!("JSON parse error: {e}");
|
||||||
Err(e) => {
|
None
|
||||||
eprintln!("JSON parse error: {e}");
|
}
|
||||||
None
|
Ok(json) => Some(json),
|
||||||
}
|
}
|
||||||
Ok(json) => Some(json),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,7 +146,8 @@ impl Runnable for HelpArgs {
|
|||||||
println!("improvise — multi-dimensional data modeling TUI\n");
|
println!("improvise — multi-dimensional data modeling TUI\n");
|
||||||
println!("USAGE:");
|
println!("USAGE:");
|
||||||
println!(" improvise [file.improv] Open or create a model");
|
println!(" improvise [file.improv] Open or create a model");
|
||||||
println!(" improvise --import data.json Import JSON (or CSV) then open TUI");
|
println!(" improvise --import data.json Import JSON or CSV then open TUI");
|
||||||
|
println!(" improvise --import a.csv b.csv Import multiple CSVs (filenames become a category)");
|
||||||
println!(" improvise --cmd '{{...}}' Run a JSON command (headless, repeatable)");
|
println!(" improvise --cmd '{{...}}' Run a JSON command (headless, repeatable)");
|
||||||
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
|
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
|
||||||
println!("\nTUI KEYS (vim-style):");
|
println!("\nTUI KEYS (vim-style):");
|
||||||
@ -154,7 +174,7 @@ fn parse_args(args: Vec<String>) -> Box<dyn Runnable> {
|
|||||||
let mut file_path: Option<PathBuf> = None;
|
let mut file_path: Option<PathBuf> = None;
|
||||||
let mut headless_cmds: Vec<String> = Vec::new();
|
let mut headless_cmds: Vec<String> = Vec::new();
|
||||||
let mut headless_script: Option<PathBuf> = None;
|
let mut headless_script: Option<PathBuf> = None;
|
||||||
let mut import_path: Option<PathBuf> = None;
|
let mut import_paths: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
@ -171,7 +191,11 @@ fn parse_args(args: Vec<String>) -> Box<dyn Runnable> {
|
|||||||
}
|
}
|
||||||
"--import" => {
|
"--import" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
import_path = args.get(i).map(PathBuf::from);
|
while i < args.len() && !args[i].starts_with('-') {
|
||||||
|
import_paths.push(PathBuf::from(&args[i]));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue; // skip the i += 1 at the bottom
|
||||||
}
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
return Box::new(HelpArgs);
|
return Box::new(HelpArgs);
|
||||||
@ -193,7 +217,7 @@ fn parse_args(args: Vec<String>) -> Box<dyn Runnable> {
|
|||||||
} else {
|
} else {
|
||||||
Box::new(CmdLineArgs {
|
Box::new(CmdLineArgs {
|
||||||
file_path,
|
file_path,
|
||||||
import_path,
|
import_paths,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user