diff --git a/.gitignore b/.gitignore index 94cc732..e6280fe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ output/ *.lock /input config.toml +config.dev.toml +config.test.toml diff --git a/config.example.toml b/config.example.toml index 235b876..11d2113 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,6 +1,6 @@ [database] host = "localhost" port = 3306 -user = "root" +user = "" password = "" name = "rusty_petroleum" diff --git a/migrations/001_dev.sql b/migrations/001_dev.sql new file mode 100644 index 0000000..509e91d --- /dev/null +++ b/migrations/001_dev.sql @@ -0,0 +1,2 @@ +-- Create development database +CREATE DATABASE IF NOT EXISTS rusty_petroleum_dev; diff --git a/migrations/001_prod.sql b/migrations/001_prod.sql new file mode 100644 index 0000000..7d9908a --- /dev/null +++ b/migrations/001_prod.sql @@ -0,0 +1,2 @@ +-- Create production database +CREATE DATABASE IF NOT EXISTS rusty_petroleum; diff --git a/migrations/001_test.sql b/migrations/001_test.sql new file mode 100644 index 0000000..15a65dd --- /dev/null +++ b/migrations/001_test.sql @@ -0,0 +1,2 @@ +-- Create test database +CREATE DATABASE IF NOT EXISTS rusty_petroleum_test; diff --git a/migrations/001_initial_schema.sql b/migrations/002_schema.sql similarity index 92% rename from migrations/001_initial_schema.sql rename to migrations/002_schema.sql index c1d74ba..9c07765 100644 --- a/migrations/001_initial_schema.sql +++ b/migrations/002_schema.sql @@ -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, diff --git a/src/commands/db.rs b/src/commands/db.rs new file mode 100644 index 0000000..3adb2fc --- /dev/null +++ b/src/commands/db.rs @@ -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(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 657d32b..41d46f4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod db; pub mod import; +pub use db::run_db_setup; pub use import::run_import; diff --git a/src/config.rs b/src/config.rs index 752bbfd..cf1c94f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { + 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 { - format!( - "mysql://{}:{}@{}:{}/{}", - self.user, self.password, self.host, self.port, self.name - ) + 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::load_from_path(Path::new("config.toml")) + pub fn load(env: Env) -> anyhow::Result { + 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) -> anyhow::Result { + pub fn load_from_path(path: &Path, env: Env) -> anyhow::Result { 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 for Config { fn from(toml: TomlConfig) -> Self { Config { + env: Env::default(), database: DatabaseConfig { host: toml.database.host, port: toml.database.port, diff --git a/src/db/repository.rs b/src/db/repository.rs index 40a5979..273b13c 100644 --- a/src/db/repository.rs +++ b/src/db/repository.rs @@ -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 { sqlx::query( r#" diff --git a/src/main.rs b/src/main.rs index bdeaafa..6ac0128 100644 --- a/src/main.rs +++ b/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 { + 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 = 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 ", args[0]); + let clean_args = remove_env_flags(&args); + if clean_args.len() != 3 { + eprintln!("Usage: {} import [--env ]", 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 ", args[0]); + let clean_args = remove_env_flags(&args); + if clean_args.len() != 4 { + eprintln!("Usage: {} generate [--env ]", 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 [--env ]", 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: {} [argument]", program); + eprintln!("Usage: {} [arguments]", program); eprintln!(); - eprintln!("Kommandon:"); - eprintln!(" import Importera CSV-data till databasen"); - eprintln!(" generate Generera HTML-fakturor från CSV"); - eprintln!(" help Visa denna hjälptext"); + eprintln!("Commands:"); + eprintln!(" import [--env ] Import CSV data to database (default: prod)"); + eprintln!(" generate Generate HTML invoices from CSV"); + eprintln!(" db setup [--env ] Create database and schema (default: prod)"); + eprintln!(" help Show this help message"); + eprintln!(); + eprintln!("Environments: prod (default), dev, test"); }