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 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 17:12:55 +01:00
commit 5cc0643a48
6 changed files with 583 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
data/*.db
.DS_Store
.idea/

53
CLAUDE.md Normal file
View File

@@ -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.

233
Cargo.lock generated Normal file
View File

@@ -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",
]

8
Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "pushWaterCar"
version = "0.1.0"
edition = "2021"
[dependencies]
csv = "1.3"
rusqlite = "0.32"

52
data/manual.csv Normal file
View File

@@ -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
1 Date Eau Scénic Zoé
2 5/1/24 38,76 94990 50621
3 13/1/24 41,64 95497 50850
4 27/1/24 47,18 95829 51491
5 3/2/24 48,28 95892 51741
6 17/2/24 53,99 96107 52503
7 25/2/24 57,60 96190 52744
8 2/3/24 60,96 96309 52963
9 23/3/24 68,78 97289
10 31/3/24 71,70 97398 54293
11 20/04/24 78,04 98083 54992
12 27/4/24 80,76 98315 55244
13 16/11/24 151,21 105935 63316
14 24/11/24 154,54 106166 63806
15 7/12/24 159,68 106562 64605
16 15/12/24 162,13 106689 65055
17 21/12/24 165,41 106806 65453
18 28/12/24 168,26 107174 65575
19 4/1/25 170,91 107174 65883
20 11/1/25 173,21 107308 66139
21 18/1/25 175,67 107460 66568
22 1/2/25 181,12 107744 67165
23 9/2/25 183,66 108148 67558
24 15/2/25 185,88 108375 67845
25 23/2/25 189,43 108560 68285
26 2/3/25 192,10 108722 68667
27 8/3/25 193,80 108833 69005
28 15/3/25 196,24 109111 69395
29 29/3/25 200,46 109639 69858
30 6/4/25 203,63 109883 70116
31 12/4/25 204,95 110217 70379
32 22/4/25 207,65 110484 70500
33 3/5/25 210,57 110721 70683
34 18/5/25 215,81 110926 70950
35 23/5/25 217,53 111019 71080
36 7/6/25 221,89 111542 71403
37 28/6/25 227,96 111822 71725
38 24/7/25 234,95 112108 72330
39 9/8/25 239,54 112329 72539
40 24/8/25 242,01 113194 72603
41 13/9/25 247,96 113340 73232
42 21/9/25 250,43 113434 73478
43 28/9/25 252,17 113528 73582
44 4/10/25 254,48 113626 73735
45 11/10/25 256,47 113745 73800
46 19/10/25 259,36 113845 74020
47 2/11/25 264,26 114238 74279
48 12/11/25 267,02 114238 74567
49 23/11/25 270,80 114780 74880
50 29/11/25 272,64 114643 74905
51 7/12/25 275,31 114812 75091
52 23/12/25 280,52 115327 75602

233
src/main.rs Normal file
View File

@@ -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<String> = env::args().collect();
if args.len() != 3 {
eprintln!("Usage: {} <csv_file> <jdbc_url>", 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<String> {
url.strip_prefix("jdbc:sqlite:").map(|s| s.to_string())
}
struct Record {
date: String,
eau: Option<f64>,
scenic: Option<i64>,
zoe: Option<i64>,
}
fn read_csv(path: &str) -> Result<Vec<Record>, 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<f64> {
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<i64> {
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::<f64>().ok().map(|f| f as i64)
}
fn get_existing_dates(conn: &Connection, table: &str) -> HashSet<String> {
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(())
}