commit 5cc0643a485ba7c050be7776d655bad41f849bab Author: Christophe Vila Date: Sun Jan 25 17:12:55 2026 +0100 Initial commit: CSV to SQLite push tool Rust CLI tool to push CSV data (water and car readings) into SQLite database. Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96a693b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +data/*.db +.DS_Store +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72a45f5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +this project is a tool CLI that can take CSV file and push its content into a sqlite database. + +the CSV files have the following columns: + +- Date +- Eau +- Scénic +- Zoé + +the sqlite databases has been created with the following command: + +CREATE TABLE "eau" +( +date TEXT not null +primary key +unique, +releve REAL not null, +comment TEXT +) + +CREATE TABLE "voiture1" ( +"date" TEXT NOT NULL UNIQUE, +"releve" INTEGER NOT NULL, +"comment" TEXT, +PRIMARY KEY("date") +) + +CREATE TABLE "voiture2" +( +date TEXT not null, +releve INTEGER not null, +comment TEXT +) + +CSV columns "Date" and "Eau" has to be pushed to database table "eau" on columns "date" and "releve". no push if "Eau" is empty +CSV columns "Date" and "Scénic" has to be pushed to database table "voiture1" on columns "date" and "releve". no push if "Scénic" is empty +CSV columns "Date" and "Zoé" has to be pushed to database table "voiture2" on columns "date" and "releve". no push if "Zoé" is empty + +the CLI takes 2 parameters: + +- first is the name of the CSV file +- second is the JDBC URL (for example, "jdbc:sqlite:/Users/kriss/NextCloud/Kriss/Dev/Perso/conso/db/conso.db") + +when launched, the tool checks that: + +- CSV file exists on the disk +- CSV file contains exactly the described columns +- Sqlite database is accessible through the JDBC URL +- Ensure there won't be any duplicates inserted in the database (which means if the tool is launched twice in a row, no new lines will be inserted) +- Tell the user how many new lines are going to be inserted in the database, and ask his agreement before proceeding +- Performs the insert in the database + +this project is written in Rust. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..defdfe5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,233 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pushWaterCar" +version = "0.1.0" +dependencies = [ + "csv", + "rusqlite", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a9e6527 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "pushWaterCar" +version = "0.1.0" +edition = "2021" + +[dependencies] +csv = "1.3" +rusqlite = "0.32" diff --git a/data/manual.csv b/data/manual.csv new file mode 100644 index 0000000..b481efc --- /dev/null +++ b/data/manual.csv @@ -0,0 +1,52 @@ +Date;Eau;Scénic;Zoé +5/1/24;38,76;94990;50621 +13/1/24;41,64;95497;50850 +27/1/24;47,18;95829;51491 +3/2/24;48,28;95892;51741 +17/2/24;53,99;96107;52503 +25/2/24;57,60;96190;52744 +2/3/24;60,96;96309;52963 +23/3/24;68,78;97289; +31/3/24;71,70;97398;54293 +20/04/24;78,04;98083;54992 +27/4/24;80,76;98315;55244 +16/11/24;151,21;105935;63316 +24/11/24;154,54;106166;63806 +7/12/24;159,68;106562;64605 +15/12/24;162,13;106689;65055 +21/12/24;165,41;106806;65453 +28/12/24;168,26;107174;65575 +4/1/25;170,91;107174;65883 +11/1/25;173,21;107308;66139 +18/1/25;175,67;107460;66568 +1/2/25;181,12;107744;67165 +9/2/25;183,66;108148;67558 +15/2/25;185,88;108375;67845 +23/2/25;189,43;108560;68285 +2/3/25;192,10;108722;68667 +8/3/25;193,80;108833;69005 +15/3/25;196,24;109111;69395 +29/3/25;200,46;109639;69858 +6/4/25;203,63;109883;70116 +12/4/25;204,95;110217;70379 +22/4/25;207,65;110484;70500 +3/5/25;210,57;110721;70683 +18/5/25;215,81;110926;70950 +23/5/25;217,53;111019;71080 +7/6/25;221,89;111542;71403 +28/6/25;227,96;111822;71725 +24/7/25;234,95;112108;72330 +9/8/25;239,54;112329;72539 +24/8/25;242,01;113194;72603 +13/9/25;247,96;113340;73232 +21/9/25;250,43;113434;73478 +28/9/25;252,17;113528;73582 +4/10/25;254,48;113626;73735 +11/10/25;256,47;113745;73800 +19/10/25;259,36;113845;74020 +2/11/25;264,26;114238;74279 +12/11/25;267,02;114238;74567 +23/11/25;270,80;114780;74880 +29/11/25;272,64;114643;74905 +7/12/25;275,31;114812;75091 +23/12/25;280,52;115327;75602 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3786b61 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,233 @@ +use csv::ReaderBuilder; +use rusqlite::Connection; +use std::collections::HashSet; +use std::env; +use std::fs::File; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::process; + +const EXPECTED_HEADERS: [&str; 4] = ["Date", "Eau", "Scénic", "Zoé"]; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() != 3 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} data/manual.csv jdbc:sqlite:/path/to/conso.db", args[0]); + process::exit(1); + } + + let csv_path = &args[1]; + let jdbc_url = &args[2]; + + // Parse JDBC URL to get SQLite path + let db_path = match parse_jdbc_url(jdbc_url) { + Some(path) => path, + None => { + eprintln!("Error: Invalid JDBC URL. Expected format: jdbc:sqlite:/path/to/database.db"); + process::exit(1); + } + }; + + // Validate CSV file exists + if !Path::new(csv_path).exists() { + eprintln!("Error: CSV file not found: {}", csv_path); + process::exit(1); + } + + // Validate database file exists + if !Path::new(&db_path).exists() { + eprintln!("Error: Database file not found: {}", db_path); + process::exit(1); + } + + // Open database connection + let conn = match Connection::open(&db_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: Cannot connect to database: {}", e); + process::exit(1); + } + }; + + // Read and validate CSV + let records = match read_csv(csv_path) { + Ok(r) => r, + Err(e) => { + eprintln!("Error reading CSV: {}", e); + process::exit(1); + } + }; + + // Get existing dates from each table + let existing_eau = get_existing_dates(&conn, "eau"); + let existing_voiture1 = get_existing_dates(&conn, "voiture1"); + let existing_voiture2 = get_existing_dates(&conn, "voiture2"); + + // Filter records to find new ones for each table + let mut new_eau: Vec<(&str, f64)> = Vec::new(); + let mut new_voiture1: Vec<(&str, i64)> = Vec::new(); + let mut new_voiture2: Vec<(&str, i64)> = Vec::new(); + + for record in &records { + if !existing_eau.contains(&record.date) { + if let Some(val) = record.eau { + new_eau.push((&record.date, val)); + } + } + if !existing_voiture1.contains(&record.date) { + if let Some(val) = record.scenic { + new_voiture1.push((&record.date, val)); + } + } + if !existing_voiture2.contains(&record.date) { + if let Some(val) = record.zoe { + new_voiture2.push((&record.date, val)); + } + } + } + + // Show summary + println!("New rows to insert:"); + println!(" - eau: {} rows", new_eau.len()); + println!(" - voiture1: {} rows", new_voiture1.len()); + println!(" - voiture2: {} rows", new_voiture2.len()); + + let total = new_eau.len() + new_voiture1.len() + new_voiture2.len(); + if total == 0 { + println!("\nNo new data to insert. Database is up to date."); + return; + } + + // Ask for confirmation + print!("\nProceed with insertion? (y/N): "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input).unwrap(); + + if input.trim().to_lowercase() != "y" { + println!("Aborted."); + return; + } + + // Perform insertions + if let Err(e) = insert_data(&conn, &new_eau, &new_voiture1, &new_voiture2) { + eprintln!("Error during insertion: {}", e); + process::exit(1); + } + + println!("Done! Inserted {} rows total.", total); +} + +fn parse_jdbc_url(url: &str) -> Option { + url.strip_prefix("jdbc:sqlite:").map(|s| s.to_string()) +} + +struct Record { + date: String, + eau: Option, + scenic: Option, + zoe: Option, +} + +fn read_csv(path: &str) -> Result, String> { + let file = File::open(path).map_err(|e| format!("Cannot open file: {}", e))?; + + let mut reader = ReaderBuilder::new() + .delimiter(b';') + .from_reader(file); + + // Validate headers + let headers = reader.headers().map_err(|e| format!("Cannot read headers: {}", e))?; + let header_vec: Vec<&str> = headers.iter().collect(); + + if header_vec != EXPECTED_HEADERS { + return Err(format!( + "Invalid CSV headers. Expected {:?}, got {:?}", + EXPECTED_HEADERS, header_vec + )); + } + + let mut records = Vec::new(); + + for (line_num, result) in reader.records().enumerate() { + let record = result.map_err(|e| format!("Error reading line {}: {}", line_num + 2, e))?; + + let date = record.get(0).unwrap_or("").trim().to_string(); + if date.is_empty() { + continue; + } + + let eau = parse_french_decimal(record.get(1).unwrap_or("")); + let scenic = parse_integer(record.get(2).unwrap_or("")); + let zoe = parse_integer(record.get(3).unwrap_or("")); + + records.push(Record { date, eau, scenic, zoe }); + } + + Ok(records) +} + +fn parse_french_decimal(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + // Replace French decimal comma with dot + let normalized = s.replace(',', "."); + normalized.parse().ok() +} + +fn parse_integer(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + // Handle potential decimal values by parsing as float first and converting + let normalized = s.replace(',', "."); + normalized.parse::().ok().map(|f| f as i64) +} + +fn get_existing_dates(conn: &Connection, table: &str) -> HashSet { + let mut dates = HashSet::new(); + let query = format!("SELECT date FROM {}", table); + + if let Ok(mut stmt) = conn.prepare(&query) { + if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) { + for date in rows.flatten() { + dates.insert(date); + } + } + } + + dates +} + +fn insert_data( + conn: &Connection, + eau: &[(&str, f64)], + voiture1: &[(&str, i64)], + voiture2: &[(&str, i64)], +) -> Result<(), rusqlite::Error> { + // Insert eau records + let mut stmt = conn.prepare("INSERT INTO eau (date, releve) VALUES (?, ?)")?; + for (date, releve) in eau { + stmt.execute(rusqlite::params![date, releve])?; + } + + // Insert voiture1 records + let mut stmt = conn.prepare("INSERT INTO voiture1 (date, releve) VALUES (?, ?)")?; + for (date, releve) in voiture1 { + stmt.execute(rusqlite::params![date, releve])?; + } + + // Insert voiture2 records + let mut stmt = conn.prepare("INSERT INTO voiture2 (date, releve) VALUES (?, ?)")?; + for (date, releve) in voiture2 { + stmt.execute(rusqlite::params![date, releve])?; + } + + Ok(()) +}