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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ output/
|
||||
*.lock
|
||||
/input
|
||||
config.toml
|
||||
config.dev.toml
|
||||
config.test.toml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[database]
|
||||
host = "localhost"
|
||||
port = 3306
|
||||
user = "root"
|
||||
user = ""
|
||||
password = ""
|
||||
name = "rusty_petroleum"
|
||||
|
||||
2
migrations/001_dev.sql
Normal file
2
migrations/001_dev.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Create development database
|
||||
CREATE DATABASE IF NOT EXISTS rusty_petroleum_dev;
|
||||
2
migrations/001_prod.sql
Normal file
2
migrations/001_prod.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Create production database
|
||||
CREATE DATABASE IF NOT EXISTS rusty_petroleum;
|
||||
2
migrations/001_test.sql
Normal file
2
migrations/001_test.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Create test database
|
||||
CREATE DATABASE IF NOT EXISTS rusty_petroleum_test;
|
||||
@@ -1,8 +1,5 @@
|
||||
-- Initial schema for rusty-petroleum
|
||||
-- Run this against your MariaDB database before importing data
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS rusty_petroleum;
|
||||
USE rusty_petroleum;
|
||||
-- Schema for rusty_petroleum
|
||||
-- Run after creating the database
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
99
src/commands/db.rs
Normal file
99
src/commands/db.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod import;
|
||||
|
||||
pub use db::run_db_setup;
|
||||
pub use import::run_import;
|
||||
|
||||
@@ -1,8 +1,48 @@
|
||||
use std::fs;
|
||||
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)]
|
||||
pub struct Config {
|
||||
pub env: Env,
|
||||
pub database: DatabaseConfig,
|
||||
}
|
||||
|
||||
@@ -17,26 +57,53 @@ pub struct DatabaseConfig {
|
||||
|
||||
impl DatabaseConfig {
|
||||
pub fn connection_url(&self) -> String {
|
||||
if self.password.is_empty() {
|
||||
format!(
|
||||
"mysql://{}@{}:{}/{}",
|
||||
self.user, self.host, self.port, self.name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"mysql://{}:{}@{}:{}/{}",
|
||||
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)
|
||||
.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)
|
||||
.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 {
|
||||
fn from(toml: TomlConfig) -> Self {
|
||||
Config {
|
||||
env: Env::default(),
|
||||
database: DatabaseConfig {
|
||||
host: toml.database.host,
|
||||
port: toml.database.port,
|
||||
|
||||
@@ -11,6 +11,10 @@ impl Repository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &MySqlPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
pub async fn upsert_customer(&self, customer: &NewCustomer) -> anyhow::Result<u32> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
||||
107
src/main.rs
107
src/main.rs
@@ -5,7 +5,7 @@ mod invoice_generator;
|
||||
|
||||
use askama::Template;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use config::Config;
|
||||
use config::{Config, Env};
|
||||
use csv::ReaderBuilder;
|
||||
use db::{create_pool, Repository};
|
||||
use invoice_generator::{group_by_customer, read_csv_file, Customer};
|
||||
@@ -233,10 +233,41 @@ struct CustomerTemplate {
|
||||
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]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
let (env, _) = parse_env_flag(&args);
|
||||
|
||||
if args.len() < 2 {
|
||||
print_usage(&args[0]);
|
||||
std::process::exit(1);
|
||||
@@ -244,32 +275,35 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
match args[1].as_str() {
|
||||
"import" => {
|
||||
if args.len() != 3 {
|
||||
eprintln!("Användning: {} import <csv-fil>", args[0]);
|
||||
let clean_args = remove_env_flags(&args);
|
||||
if clean_args.len() != 3 {
|
||||
eprintln!("Usage: {} import <csv-file> [--env <name>]", clean_args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let csv_path = PathBuf::from(&args[2]);
|
||||
let csv_path = PathBuf::from(&clean_args[2]);
|
||||
if !csv_path.exists() {
|
||||
eprintln!("Fel: Filen hittades inte: {:?}", csv_path);
|
||||
eprintln!("Error: File not found: {:?}", csv_path);
|
||||
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 repo = Repository::new(pool);
|
||||
|
||||
commands::run_import(&csv_path, &repo).await?;
|
||||
}
|
||||
"generate" => {
|
||||
if args.len() != 3 {
|
||||
eprintln!("Användning: {} generate <csv-fil> <utdatakatalog>", args[0]);
|
||||
let clean_args = remove_env_flags(&args);
|
||||
if clean_args.len() != 4 {
|
||||
eprintln!("Usage: {} generate <csv-file> <output-dir> [--env <name>]", clean_args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let input_path = Path::new(&args[2]);
|
||||
let base_output_dir = Path::new(&args[3]);
|
||||
let input_path = Path::new(&clean_args[2]);
|
||||
let base_output_dir = Path::new(&clean_args[3]);
|
||||
|
||||
if !input_path.exists() {
|
||||
eprintln!("Fel: Filen hittades inte: {:?}", input_path);
|
||||
eprintln!("Error: File not found: {:?}", input_path);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -279,7 +313,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
println!("Konverterar {} till rensat format...", filename);
|
||||
println!("Converting {} to cleaned format...", filename);
|
||||
|
||||
let temp_cleaned_path =
|
||||
base_output_dir.join(format!("{}.temp.csv", filename.trim_end_matches(".txt")));
|
||||
@@ -295,7 +329,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
)?;
|
||||
|
||||
println!(
|
||||
"Konverterade {} transaktioner",
|
||||
"Converted {} transactions",
|
||||
fs::read_to_string(output_dir.join(format!("{}.csv", batch_number)))?
|
||||
.lines()
|
||||
.count()
|
||||
@@ -303,7 +337,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
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 last_date = batch.transactions.last().map(|t| t.date).unwrap();
|
||||
@@ -343,19 +377,45 @@ async fn main() -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
let filename = format!("customer_{}.html", customer_num);
|
||||
fs::write(output_dir.join(&filename), customer_html)?;
|
||||
println!("Genererade {}", filename);
|
||||
println!("Generated {}", filename);
|
||||
}
|
||||
|
||||
println!(
|
||||
"\nGenererade {} kundfakturor i {:?}",
|
||||
"\nGenerated {} customer invoices in {:?}",
|
||||
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" => {
|
||||
print_usage(&args[0]);
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Okänt kommando: {}", args[1]);
|
||||
eprintln!("Unknown command: {}", args[1]);
|
||||
print_usage(&args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -365,10 +425,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
fn print_usage(program: &str) {
|
||||
eprintln!("Användning: {} <kommando> [argument]", program);
|
||||
eprintln!("Usage: {} <command> [arguments]", program);
|
||||
eprintln!();
|
||||
eprintln!("Kommandon:");
|
||||
eprintln!(" import <csv-fil> Importera CSV-data till databasen");
|
||||
eprintln!(" generate <csv-fil> <dir> Generera HTML-fakturor från CSV");
|
||||
eprintln!(" help Visa denna hjälptext");
|
||||
eprintln!("Commands:");
|
||||
eprintln!(" import <csv-file> [--env <name>] Import CSV data to database (default: prod)");
|
||||
eprintln!(" generate <csv> <dir> Generate HTML invoices from CSV");
|
||||
eprintln!(" db setup [--env <name>] Create database and schema (default: prod)");
|
||||
eprintln!(" help Show this help message");
|
||||
eprintln!();
|
||||
eprintln!("Environments: prod (default), dev, test");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user