Add multi-environment support for database configuration

Introduces separate databases and config files for dev, test, and prod
environments. The application now defaults to production, with --env flag
to specify alternative environments.

Changes:
- Update config.rs to support env-based loading (config.toml -> config.<env>.toml -> config.example.toml)
- Add Env enum (Prod, Dev, Test) with database name mapping
- Add --env flag to CLI commands (defaults to prod)
- Add 'db setup' command to create database and schema
- Split migrations into env-specific database creation and shared schema
- Update .gitignore to track config.example.toml but ignore config.toml and config.<env>.toml files
- Update config.example.toml as a template with placeholder values
- Delete 001_initial_schema.sql, replaced by 002_schema.sql + env-specific files

Config loading order:
  1. config.toml (local override)
  2. config.<env>.toml (environment-specific)
  3. config.example.toml (fallback)

Database names:
  - prod: rusty_petroleum
  - dev:  rusty_petroleum_dev
  - test: rusty_petroleum_test

Usage:
  cargo run -- db setup --env dev       # Setup dev database
  cargo run -- import data.csv --env dev # Import to dev
  cargo run -- db setup                # Setup prod (default)
  cargo run -- import data.csv         # Import to prod (default)
This commit is contained in:
2026-04-02 07:09:06 +02:00
parent 9daa186ff6
commit cd46368f79
11 changed files with 279 additions and 38 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ output/
*.lock *.lock
/input /input
config.toml config.toml
config.dev.toml
config.test.toml

View File

@@ -1,6 +1,6 @@
[database] [database]
host = "localhost" host = "localhost"
port = 3306 port = 3306
user = "root" user = ""
password = "" password = ""
name = "rusty_petroleum" name = "rusty_petroleum"

2
migrations/001_dev.sql Normal file
View File

@@ -0,0 +1,2 @@
-- Create development database
CREATE DATABASE IF NOT EXISTS rusty_petroleum_dev;

2
migrations/001_prod.sql Normal file
View File

@@ -0,0 +1,2 @@
-- Create production database
CREATE DATABASE IF NOT EXISTS rusty_petroleum;

2
migrations/001_test.sql Normal file
View File

@@ -0,0 +1,2 @@
-- Create test database
CREATE DATABASE IF NOT EXISTS rusty_petroleum_test;

View File

@@ -1,8 +1,5 @@
-- Initial schema for rusty-petroleum -- Schema for rusty_petroleum
-- Run this against your MariaDB database before importing data -- Run after creating the database
CREATE DATABASE IF NOT EXISTS rusty_petroleum;
USE rusty_petroleum;
CREATE TABLE IF NOT EXISTS customers ( CREATE TABLE IF NOT EXISTS customers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

99
src/commands/db.rs Normal file
View File

@@ -0,0 +1,99 @@
use crate::config::Config;
use crate::db::Repository;
use sqlx::mysql::MySqlPoolOptions;
pub async fn run_db_setup(repo: &Repository, config: &Config) -> anyhow::Result<()> {
let env = &config.env;
println!("Setting up database for environment: {}", env.as_str());
println!("Database: {}", env.database_name());
let database_url = &config.database.connection_url();
let base_url = database_url.trim_end_matches(env.database_name());
let setup_pool = MySqlPoolOptions::new()
.max_connections(1)
.connect(base_url)
.await?;
println!("Creating database if not exists...");
sqlx::query(&format!(
"CREATE DATABASE IF NOT EXISTS {}",
env.database_name()
))
.execute(&setup_pool)
.await?;
println!("Database '{}' ready", env.database_name());
drop(setup_pool);
println!("Creating tables...");
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS customers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
customer_number VARCHAR(50) NOT NULL UNIQUE,
card_report_group TINYINT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_customer_number (customer_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"#,
)
.execute(repo.pool())
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS cards (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
card_number VARCHAR(50) NOT NULL UNIQUE,
card_type VARCHAR(50),
customer_id INT UNSIGNED NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_card_number (card_number),
INDEX idx_customer_id (customer_id),
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"#,
)
.execute(repo.pool())
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS transactions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
transaction_date DATETIME NOT NULL,
batch_number VARCHAR(20) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
volume DECIMAL(10,3) NOT NULL,
price DECIMAL(8,4) NOT NULL,
quality_code INT NOT NULL,
quality_name VARCHAR(50) NOT NULL,
station VARCHAR(20) NOT NULL,
terminal VARCHAR(10) NOT NULL,
pump VARCHAR(10) NOT NULL,
receipt VARCHAR(20) NOT NULL,
control_number VARCHAR(20),
card_id INT UNSIGNED NOT NULL,
customer_id INT UNSIGNED NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_transaction_date (transaction_date),
INDEX idx_batch_number (batch_number),
INDEX idx_card_id (card_id),
INDEX idx_customer_id (customer_id),
INDEX idx_station (station),
FOREIGN KEY (card_id) REFERENCES cards(id),
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"#,
)
.execute(repo.pool())
.await?;
println!("Tables created successfully.");
println!("Database setup complete!");
Ok(())
}

View File

@@ -1,3 +1,5 @@
pub mod db;
pub mod import; pub mod import;
pub use db::run_db_setup;
pub use import::run_import; pub use import::run_import;

View File

@@ -1,8 +1,48 @@
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Default, PartialEq)]
pub enum Env {
#[default]
Prod,
Dev,
Test,
}
impl Env {
pub fn as_str(&self) -> &str {
match self {
Env::Prod => "prod",
Env::Dev => "dev",
Env::Test => "test",
}
}
pub fn database_name(&self) -> &str {
match self {
Env::Prod => "rusty_petroleum",
Env::Dev => "rusty_petroleum_dev",
Env::Test => "rusty_petroleum_test",
}
}
}
impl std::str::FromStr for Env {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"prod" | "production" => Ok(Env::Prod),
"dev" | "development" => Ok(Env::Dev),
"test" | "testing" => Ok(Env::Test),
_ => Err(format!("Unknown environment: {}", s)),
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub env: Env,
pub database: DatabaseConfig, pub database: DatabaseConfig,
} }
@@ -17,26 +57,53 @@ pub struct DatabaseConfig {
impl DatabaseConfig { impl DatabaseConfig {
pub fn connection_url(&self) -> String { pub fn connection_url(&self) -> String {
if self.password.is_empty() {
format!(
"mysql://{}@{}:{}/{}",
self.user, self.host, self.port, self.name
)
} else {
format!( format!(
"mysql://{}:{}@{}:{}/{}", "mysql://{}:{}@{}:{}/{}",
self.user, self.password, self.host, self.port, self.name self.user, self.password, self.host, self.port, self.name
) )
} }
} }
impl Config {
pub fn load() -> anyhow::Result<Self> {
Self::load_from_path(Path::new("config.toml"))
} }
pub fn load_from_path(path: &Path) -> anyhow::Result<Self> { impl Config {
pub fn load(env: Env) -> anyhow::Result<Self> {
let config_path = Path::new("config.toml");
let example_path = Path::new("config.example.toml");
let env_config_filename = format!("config.{}.toml", env.as_str());
let env_config_path = Path::new(&env_config_filename);
let path = if config_path.exists() {
config_path
} else if env_config_path.exists() {
env_config_path
} else if example_path.exists() {
example_path
} else {
return Err(anyhow::anyhow!(
"No configuration file found. Create config.example.toml or config.toml"
));
};
Self::load_from_path(path, env)
}
pub fn load_from_path(path: &Path, env: Env) -> anyhow::Result<Self> {
let contents = fs::read_to_string(path) let contents = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read config file: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to read config file {:?}: {}", path, e))?;
let config: TomlConfig = toml::from_str(&contents) let config: TomlConfig = toml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("Failed to parse config file: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to parse config file {:?}: {}", path, e))?;
Ok(config.into()) let mut result: Config = config.into();
result.env = env;
Ok(result)
} }
} }
@@ -57,6 +124,7 @@ struct TomlDatabaseConfig {
impl From<TomlConfig> for Config { impl From<TomlConfig> for Config {
fn from(toml: TomlConfig) -> Self { fn from(toml: TomlConfig) -> Self {
Config { Config {
env: Env::default(),
database: DatabaseConfig { database: DatabaseConfig {
host: toml.database.host, host: toml.database.host,
port: toml.database.port, port: toml.database.port,

View File

@@ -11,6 +11,10 @@ impl Repository {
Self { pool } Self { pool }
} }
pub fn pool(&self) -> &MySqlPool {
&self.pool
}
pub async fn upsert_customer(&self, customer: &NewCustomer) -> anyhow::Result<u32> { pub async fn upsert_customer(&self, customer: &NewCustomer) -> anyhow::Result<u32> {
sqlx::query( sqlx::query(
r#" r#"

View File

@@ -5,7 +5,7 @@ mod invoice_generator;
use askama::Template; use askama::Template;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use config::Config; use config::{Config, Env};
use csv::ReaderBuilder; use csv::ReaderBuilder;
use db::{create_pool, Repository}; use db::{create_pool, Repository};
use invoice_generator::{group_by_customer, read_csv_file, Customer}; use invoice_generator::{group_by_customer, read_csv_file, Customer};
@@ -233,10 +233,41 @@ struct CustomerTemplate {
generated_date: String, generated_date: String,
} }
fn parse_env_flag(args: &[String]) -> (Env, usize) {
for (i, arg) in args.iter().enumerate() {
if arg == "--env" && i + 1 < args.len() {
match args[i + 1].parse() {
Ok(env) => return (env, i),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
}
(Env::default(), 0)
}
fn remove_env_flags(args: &[String]) -> Vec<String> {
let (_, env_idx) = parse_env_flag(args);
let mut result = Vec::with_capacity(args.len());
for (i, arg) in args.iter().enumerate() {
if i == env_idx || (i == env_idx + 1 && args.get(env_idx) == Some(&"--env".to_string())) {
continue;
}
result.push(arg.clone());
}
result
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let (env, _) = parse_env_flag(&args);
if args.len() < 2 { if args.len() < 2 {
print_usage(&args[0]); print_usage(&args[0]);
std::process::exit(1); std::process::exit(1);
@@ -244,32 +275,35 @@ async fn main() -> anyhow::Result<()> {
match args[1].as_str() { match args[1].as_str() {
"import" => { "import" => {
if args.len() != 3 { let clean_args = remove_env_flags(&args);
eprintln!("Användning: {} import <csv-fil>", args[0]); if clean_args.len() != 3 {
eprintln!("Usage: {} import <csv-file> [--env <name>]", clean_args[0]);
std::process::exit(1); std::process::exit(1);
} }
let csv_path = PathBuf::from(&args[2]); let csv_path = PathBuf::from(&clean_args[2]);
if !csv_path.exists() { if !csv_path.exists() {
eprintln!("Fel: Filen hittades inte: {:?}", csv_path); eprintln!("Error: File not found: {:?}", csv_path);
std::process::exit(1); std::process::exit(1);
} }
let config = Config::load()?; println!("Environment: {}", env.as_str());
let config = Config::load(env)?;
let pool = create_pool(&config.database.connection_url()).await?; let pool = create_pool(&config.database.connection_url()).await?;
let repo = Repository::new(pool); let repo = Repository::new(pool);
commands::run_import(&csv_path, &repo).await?; commands::run_import(&csv_path, &repo).await?;
} }
"generate" => { "generate" => {
if args.len() != 3 { let clean_args = remove_env_flags(&args);
eprintln!("Användning: {} generate <csv-fil> <utdatakatalog>", args[0]); if clean_args.len() != 4 {
eprintln!("Usage: {} generate <csv-file> <output-dir> [--env <name>]", clean_args[0]);
std::process::exit(1); std::process::exit(1);
} }
let input_path = Path::new(&args[2]); let input_path = Path::new(&clean_args[2]);
let base_output_dir = Path::new(&args[3]); let base_output_dir = Path::new(&clean_args[3]);
if !input_path.exists() { if !input_path.exists() {
eprintln!("Fel: Filen hittades inte: {:?}", input_path); eprintln!("Error: File not found: {:?}", input_path);
std::process::exit(1); std::process::exit(1);
} }
@@ -279,7 +313,7 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
println!("Konverterar {} till rensat format...", filename); println!("Converting {} to cleaned format...", filename);
let temp_cleaned_path = let temp_cleaned_path =
base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt"))); base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt")));
@@ -295,7 +329,7 @@ async fn main() -> anyhow::Result<()> {
)?; )?;
println!( println!(
"Konverterade {} transaktioner", "Converted {} transactions",
fs::read_to_string(output_dir.join(format!("{}.csv", batch_number)))? fs::read_to_string(output_dir.join(format!("{}.csv", batch_number)))?
.lines() .lines()
.count() .count()
@@ -303,7 +337,7 @@ async fn main() -> anyhow::Result<()> {
); );
let batch = read_csv_file(&output_dir.join(format!("{}.csv", batch_number)))?; let batch = read_csv_file(&output_dir.join(format!("{}.csv", batch_number)))?;
println!("Laddade {} transaktioner", batch.transactions.len()); println!("Loaded {} transactions", batch.transactions.len());
let first_date = batch.transactions.first().map(|t| t.date).unwrap(); let first_date = batch.transactions.first().map(|t| t.date).unwrap();
let last_date = batch.transactions.last().map(|t| t.date).unwrap(); let last_date = batch.transactions.last().map(|t| t.date).unwrap();
@@ -343,19 +377,45 @@ async fn main() -> anyhow::Result<()> {
.unwrap(); .unwrap();
let filename = format!("customer_{}.html", customer_num); let filename = format!("customer_{}.html", customer_num);
fs::write(output_dir.join(&filename), customer_html)?; fs::write(output_dir.join(&filename), customer_html)?;
println!("Genererade {}", filename); println!("Generated {}", filename);
} }
println!( println!(
"\nGenererade {} kundfakturor i {:?}", "\nGenerated {} customer invoices in {:?}",
customer_count, output_dir customer_count, output_dir
); );
} }
"db" => {
let clean_args = remove_env_flags(&args);
if clean_args.len() < 3 {
eprintln!("Usage: {} db <subcommand> [--env <name>]", clean_args[0]);
eprintln!("Subcommands:");
eprintln!(" setup Create database and schema");
std::process::exit(1);
}
println!("Environment: {}", env.as_str());
let config = Config::load(env)?;
let pool = create_pool(&config.database.connection_url()).await?;
let repo = Repository::new(pool);
match clean_args[2].as_str() {
"setup" => {
commands::run_db_setup(&repo, &config).await?;
}
_ => {
eprintln!("Unknown db subcommand: {}", clean_args[2]);
eprintln!("Subcommands:");
eprintln!(" setup Create database and schema");
std::process::exit(1);
}
}
}
"help" | "--help" | "-h" => { "help" | "--help" | "-h" => {
print_usage(&args[0]); print_usage(&args[0]);
} }
_ => { _ => {
eprintln!("Okänt kommando: {}", args[1]); eprintln!("Unknown command: {}", args[1]);
print_usage(&args[0]); print_usage(&args[0]);
std::process::exit(1); std::process::exit(1);
} }
@@ -365,10 +425,13 @@ async fn main() -> anyhow::Result<()> {
} }
fn print_usage(program: &str) { fn print_usage(program: &str) {
eprintln!("Användning: {} <kommando> [argument]", program); eprintln!("Usage: {} <command> [arguments]", program);
eprintln!(); eprintln!();
eprintln!("Kommandon:"); eprintln!("Commands:");
eprintln!(" import <csv-fil> Importera CSV-data till databasen"); eprintln!(" import <csv-file> [--env <name>] Import CSV data to database (default: prod)");
eprintln!(" generate <csv-fil> <dir> Generera HTML-fakturor från CSV"); eprintln!(" generate <csv> <dir> Generate HTML invoices from CSV");
eprintln!(" help Visa denna hjälptext"); eprintln!(" db setup [--env <name>] Create database and schema (default: prod)");
eprintln!(" help Show this help message");
eprintln!();
eprintln!("Environments: prod (default), dev, test");
} }