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 <state>/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).
This commit is contained in:
2026-09-08 18:17:00 +02:00
parent e81ef6b351
commit a81dd6f543
4 changed files with 225 additions and 0 deletions
+1
View File
@@ -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]
+191
View File
@@ -0,0 +1,191 @@
//! Configuration loading and resolution.
//!
//! Lookup order: `--config <path>` 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 <state 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}");
}
}
+2
View File
@@ -1 +1,3 @@
//! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry.
pub mod config;