From a81dd6f5431eeb8734c907cd4f8cfa73efaa09a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20R=C3=B6nnb=C3=A4ck?= Date: Tue, 8 Sep 2026 18:17:00 +0200 Subject: [PATCH] config: add failing tests for load, resolution, defaults, masking Tests define the API for the config module: - Config::load_with(path, home) / Config::load(path) read the TOML file (database/web/state/backup sections per config.template.toml) - empty strings resolve to built-in defaults: db port 3306, web port 8080, state dir $HOME/.config/rpn, backup dir /backups (backup follows an explicitly set state dir) - port may be written as a TOML int or a string in real configs; non-numeric strings are a ConfigError - resolve_config_path(): --config flag > $RUSTYRPN_CONFIG > cwd/config.toml; --env=dev|test only affects that last default slot - Config::show() renders the effective config with the password masked - Config::validate_database() requires host/user/name Also adds the toml crate (forced by the TOML config decision). --- Application/Cargo.lock | 31 +++++ Application/src/core/Cargo.toml | 1 + Application/src/core/src/config.rs | 191 +++++++++++++++++++++++++++++ Application/src/core/src/lib.rs | 2 + 4 files changed, 225 insertions(+) create mode 100644 Application/src/core/src/config.rs diff --git a/Application/Cargo.lock b/Application/Cargo.lock index 2cfc6ca..9769460 100644 --- a/Application/Cargo.lock +++ b/Application/Cargo.lock @@ -1272,6 +1272,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "toml", "tracing", ] @@ -1386,6 +1387,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1861,6 +1871,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1891,6 +1916,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" diff --git a/Application/src/core/Cargo.toml b/Application/src/core/Cargo.toml index 593664d..2241ed6 100644 --- a/Application/src/core/Cargo.toml +++ b/Application/src/core/Cargo.toml @@ -14,6 +14,7 @@ serde_json = "1.0.151" sha2 = "0.11.0" sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio", "chrono", "rust_decimal"] } thiserror = "2.0.20" +toml = "1.1.5" tracing = "0.1.44" [dev-dependencies] diff --git a/Application/src/core/src/config.rs b/Application/src/core/src/config.rs new file mode 100644 index 0000000..8843641 --- /dev/null +++ b/Application/src/core/src/config.rs @@ -0,0 +1,191 @@ +//! Configuration loading and resolution. +//! +//! Lookup order: `--config ` flag, then `$RUSTYRPN_CONFIG`, then +//! `config.toml` in the current directory. `--env=dev|test` selects +//! `config.dev.toml` / `config.test.toml` in that last default slot only; +//! an explicit `--config` path or `$RUSTYRPN_CONFIG` file wins over it. +//! An empty string in the file means "use the built-in default" where one +//! exists (database port 3306, web port 8080, state dir ~/.config/rpn, +//! backup dir /backups). + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + const FULL_TOML: &str = r#" +[database] +host = "db.example" +port = 3307 +user = "alice" +password = "hunter2" +name = "rpn_prod" + +[web] +port = 9090 +fqdn = "rpn.example.com" + +[state] +dir = "/var/lib/rpn" + +[backup] +dir = "/var/backup/rpn" +"#; + + fn dir_with(name: &str, body: &str) -> (tempfile::TempDir, PathBuf) { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join(name); + std::fs::write(&p, body).unwrap(); + (d, p) + } + + #[test] + fn load_reads_all_sections() { + let (_d, p) = dir_with("config.toml", FULL_TOML); + let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap(); + assert_eq!(cfg.database.host, "db.example"); + assert_eq!(cfg.database.port, 3307); + assert_eq!(cfg.database.user, "alice"); + assert_eq!(cfg.database.password, "hunter2"); + assert_eq!(cfg.database.name, "rpn_prod"); + assert_eq!(cfg.web.port, 9090); + assert_eq!(cfg.web.fqdn, "rpn.example.com"); + assert_eq!(cfg.state.dir, "/var/lib/rpn"); + assert_eq!(cfg.backup.dir, "/var/backup/rpn"); + } + + #[test] + fn missing_file_is_not_found_error() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("nope.toml"); + let err = Config::load_with(&p, Path::new("/home/u")).unwrap_err(); + assert!(matches!(err, ConfigError::NotFound(ref q) if *q == p), "{err:?}"); + } + + #[test] + fn malformed_toml_is_parse_error() { + let (_d, p) = dir_with("config.toml", "this is [not toml"); + let err = Config::load_with(&p, Path::new("/home/u")).unwrap_err(); + assert!(matches!(err, ConfigError::Parse { .. }), "{err:?}"); + } + + #[test] + fn empty_port_becomes_mysql_default() { + let body = " +[database] +host = \"db\" +port = \"\" +user = \"u\" +password = \"\" +name = \"db\" +"; + let (_d, p) = dir_with("config.toml", body); + let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap(); + assert_eq!(cfg.database.port, 3306); + } + + #[test] + fn non_numeric_port_is_invalid() { + let body = " +[database] +host = \"db\" +port = \"abc\" +user = \"u\" +password = \"\" +name = \"db\" +"; + let (_d, p) = dir_with("config.toml", body); + let err = Config::load_with(&p, Path::new("/home/u")).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid { .. }), "{err:?}"); + } + + #[test] + fn missing_sections_get_defaults() { + let body = "[database]\nhost = \"db\"\nport = \"\"\nuser = \"u\"\npassword = \"\"\nname = \"db\"\n"; + let (_d, p) = dir_with("config.toml", body); + let cfg = Config::load_with(&p, Path::new("/home/x")).unwrap(); + assert_eq!(cfg.web.port, 8080); + assert!(cfg.web.fqdn.is_empty()); + assert_eq!(cfg.state.dir, "/home/x/.config/rpn"); + assert_eq!(cfg.backup.dir, "/home/x/.config/rpn/backups"); + } + + #[test] + fn backup_default_follows_explicit_state_dir() { + let body = " +[database] +host = \"db\" +port = \"\" +user = \"u\" +password = \"\" +name = \"db\" + +[state] +dir = \"/opt/rpn\" +"; + let (_d, p) = dir_with("config.toml", body); + let cfg = Config::load_with(&p, Path::new("/home/x")).unwrap(); + assert_eq!(cfg.state.dir, "/opt/rpn"); + assert_eq!(cfg.backup.dir, "/opt/rpn/backups"); + } + + #[test] + fn resolve_prefers_explicit_flag() { + let path = resolve_config_path( + Some(Path::new("/flag/config.toml")), + Some("env-file.toml"), + Path::new("/cwd"), + Some("dev"), + ); + assert_eq!(path, PathBuf::from("/flag/config.toml")); + } + + #[test] + fn resolve_prefers_env_over_cwd_default() { + let path = resolve_config_path(None, Some("/env/cfg.toml"), Path::new("/cwd"), Some("dev")); + assert_eq!(path, PathBuf::from("/env/cfg.toml")); + } + + #[test] + fn resolve_env_name_selects_env_suffixed_default() { + let path = resolve_config_path(None, None, Path::new("/cwd"), Some("test")); + assert_eq!(path, Path::new("/cwd").join("config.test.toml")); + } + + #[test] + fn resolve_no_env_uses_production_default() { + let path = resolve_config_path(None, None, Path::new("/cwd"), None); + assert_eq!(path, Path::new("/cwd").join("config.toml")); + } + + #[test] + fn show_masks_password() { + let (_d, p) = dir_with("config.toml", FULL_TOML); + let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap(); + let s = cfg.show(); + assert!(!s.contains("hunter2"), "password leaked into show output:\n{s}"); + assert!(s.contains("***"), "{s}"); + assert!(s.contains("alice"), "user should remain visible:\n{s}"); + } + + #[test] + fn show_applies_defaults() { + let body = "[database]\nhost = \"db\"\nport = \"\"\nuser = \"u\"\npassword = \"\"\nname = \"db\"\n"; + let (_d, p) = dir_with("config.toml", body); + let cfg = Config::load_with(&p, Path::new("/home/x")).unwrap(); + let s = cfg.show(); + assert!(s.contains("3306"), "{s}"); + assert!(s.contains("/home/x/.config/rpn"), "{s}"); + } + + #[test] + fn validate_database_requires_host_user_name() { + let body = "[database]\nhost = \"\"\nport = \"\"\nuser = \"u\"\npassword = \"\"\nname = \"\"\n"; + let (_d, p) = dir_with("config.toml", body); + let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap(); + let err = cfg.validate_database().unwrap_err(); + assert!(err.to_string().contains("host"), "{err}"); + assert!(err.to_string().contains("name"), "{err}"); + assert!(!err.to_string().contains("user"), "user is set: {err}"); + } +} diff --git a/Application/src/core/src/lib.rs b/Application/src/core/src/lib.rs index 69e1f85..117feac 100644 --- a/Application/src/core/src/lib.rs +++ b/Application/src/core/src/lib.rs @@ -1 +1,3 @@ //! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry. + +pub mod config;