Initial commit: CSV to SQLite CLI tool
CLI tool to import EDF electricity meter readings from CSV files into a SQLite database. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
233
src/main.rs
Normal file
233
src/main.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use csv::ReaderBuilder;
|
||||
use encoding_rs::ISO_8859_15;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
const EXPECTED_COLUMNS: [&str; 4] = [
|
||||
"Date de relevé de l'index",
|
||||
"Type d'index",
|
||||
"Index Heures pleines (kWh)",
|
||||
"Index Heures creuses (kWh)",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Record {
|
||||
date: String,
|
||||
releve_hp: i64,
|
||||
releve_hc: i64,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() != 3 {
|
||||
bail!(
|
||||
"Usage: {} <csv_file> <jdbc_url>\nExample: {} data.csv \"jdbc:sqlite:/path/to/conso.db\"",
|
||||
args[0],
|
||||
args[0]
|
||||
);
|
||||
}
|
||||
|
||||
let csv_path = &args[1];
|
||||
let jdbc_url = &args[2];
|
||||
|
||||
// Check CSV file exists
|
||||
if !Path::new(csv_path).exists() {
|
||||
bail!("CSV file '{}' does not exist", csv_path);
|
||||
}
|
||||
|
||||
// Parse JDBC URL to get SQLite path
|
||||
let db_path = parse_jdbc_url(jdbc_url)?;
|
||||
|
||||
// Check database is accessible
|
||||
let conn = Connection::open(&db_path)
|
||||
.with_context(|| format!("Failed to open database at '{}'", db_path))?;
|
||||
|
||||
// Verify the table exists
|
||||
verify_table_exists(&conn)?;
|
||||
|
||||
// Read and validate CSV
|
||||
let records = read_csv(csv_path)?;
|
||||
println!("Read {} records from CSV file", records.len());
|
||||
|
||||
// Get existing dates from database
|
||||
let existing_dates = get_existing_dates(&conn)?;
|
||||
|
||||
// Filter out duplicates
|
||||
let new_records: Vec<&Record> = records
|
||||
.iter()
|
||||
.filter(|r| !existing_dates.contains(&r.date))
|
||||
.collect();
|
||||
|
||||
if new_records.is_empty() {
|
||||
println!("No new records to insert. All records already exist in the database.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} new record(s) will be inserted into the database:",
|
||||
new_records.len()
|
||||
);
|
||||
for record in &new_records {
|
||||
println!(
|
||||
" - {} (HP: {}, HC: {})",
|
||||
record.date, record.releve_hp, record.releve_hc
|
||||
);
|
||||
}
|
||||
|
||||
// Ask for user confirmation
|
||||
print!("\nDo you want to proceed? [y/N] ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
println!("Operation cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Insert records
|
||||
insert_records(&conn, &new_records)?;
|
||||
|
||||
println!(
|
||||
"Successfully inserted {} record(s) into the database.",
|
||||
new_records.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_jdbc_url(jdbc_url: &str) -> Result<String> {
|
||||
// Expected format: jdbc:sqlite:/path/to/database.db
|
||||
let prefix = "jdbc:sqlite:";
|
||||
if !jdbc_url.starts_with(prefix) {
|
||||
bail!(
|
||||
"Invalid JDBC URL format. Expected 'jdbc:sqlite:/path/to/db', got '{}'",
|
||||
jdbc_url
|
||||
);
|
||||
}
|
||||
|
||||
let path = &jdbc_url[prefix.len()..];
|
||||
if path.is_empty() {
|
||||
bail!("Database path in JDBC URL is empty");
|
||||
}
|
||||
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
fn verify_table_exists(conn: &Connection) -> Result<()> {
|
||||
let table_exists: bool = conn.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='electricite'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
if !table_exists {
|
||||
bail!("Table 'electricite' does not exist in the database");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_csv(path: &str) -> Result<Vec<Record>> {
|
||||
// Read file as bytes and convert from ISO-8859-15 to UTF-8
|
||||
// (EDF CSV exports are in ISO-8859-15 encoding)
|
||||
let bytes = fs::read(path).with_context(|| format!("Failed to read CSV file '{}'", path))?;
|
||||
let (content, _, had_errors) = ISO_8859_15.decode(&bytes);
|
||||
if had_errors {
|
||||
bail!("Failed to decode CSV file from ISO-8859-15 encoding");
|
||||
}
|
||||
|
||||
let mut reader = ReaderBuilder::new()
|
||||
.delimiter(b';')
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
// Validate headers
|
||||
let headers = reader.headers()?.clone();
|
||||
let header_vec: Vec<&str> = headers.iter().collect();
|
||||
|
||||
for expected in &EXPECTED_COLUMNS {
|
||||
if !header_vec.contains(expected) {
|
||||
bail!(
|
||||
"CSV file is missing required column '{}'. Found columns: {:?}",
|
||||
expected,
|
||||
header_vec
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Find column indices
|
||||
let date_idx = header_vec
|
||||
.iter()
|
||||
.position(|&h| h == "Date de relevé de l'index")
|
||||
.ok_or_else(|| anyhow!("Column 'Date de relevé de l'index' not found"))?;
|
||||
let hp_idx = header_vec
|
||||
.iter()
|
||||
.position(|&h| h == "Index Heures pleines (kWh)")
|
||||
.ok_or_else(|| anyhow!("Column 'Index Heures pleines (kWh)' not found"))?;
|
||||
let hc_idx = header_vec
|
||||
.iter()
|
||||
.position(|&h| h == "Index Heures creuses (kWh)")
|
||||
.ok_or_else(|| anyhow!("Column 'Index Heures creuses (kWh)' not found"))?;
|
||||
|
||||
let mut records = Vec::new();
|
||||
|
||||
for (line_num, result) in reader.records().enumerate() {
|
||||
let record = result.with_context(|| format!("Failed to parse CSV line {}", line_num + 2))?;
|
||||
|
||||
let date = record
|
||||
.get(date_idx)
|
||||
.ok_or_else(|| anyhow!("Missing date value at line {}", line_num + 2))?
|
||||
.to_string();
|
||||
|
||||
let hp_str = record
|
||||
.get(hp_idx)
|
||||
.ok_or_else(|| anyhow!("Missing HP value at line {}", line_num + 2))?;
|
||||
let releve_hp: i64 = hp_str
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid HP value '{}' at line {}", hp_str, line_num + 2))?;
|
||||
|
||||
let hc_str = record
|
||||
.get(hc_idx)
|
||||
.ok_or_else(|| anyhow!("Missing HC value at line {}", line_num + 2))?;
|
||||
let releve_hc: i64 = hc_str
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid HC value '{}' at line {}", hc_str, line_num + 2))?;
|
||||
|
||||
records.push(Record {
|
||||
date,
|
||||
releve_hp,
|
||||
releve_hc,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn get_existing_dates(conn: &Connection) -> Result<HashSet<String>> {
|
||||
let mut stmt = conn.prepare("SELECT date FROM electricite")?;
|
||||
let dates: HashSet<String> = stmt
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(dates)
|
||||
}
|
||||
|
||||
fn insert_records(conn: &Connection, records: &[&Record]) -> Result<()> {
|
||||
let mut stmt =
|
||||
conn.prepare("INSERT INTO electricite (date, releveHC, releveHP) VALUES (?1, ?2, ?3)")?;
|
||||
|
||||
for record in records {
|
||||
stmt.execute((&record.date, record.releve_hc, record.releve_hp))
|
||||
.with_context(|| format!("Failed to insert record for date '{}'", record.date))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user