An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(MM-69): configuration system — relay.toml parsing #7

Summary#

  • Adds Config struct to the common crate (serde-deserializable from TOML) with v0.1 fields: bind_address, port, data_dir,database_url, public_url, and empty stub sections [blobs], [oauth], [iroh]
  • EZPDS_* env var overrides (prefix: EZPDS_) applied on top of TOML via pure apply_env_overrides — no I/O in the Functional Core
  • Relay binary gains --config/EZPDS_CONFIG CLI arg (clap), loads config on startup, fails fast with clear error message on invalid config; logging via RUST_LOG-aware EnvFilter
  • 13 tests: TOML parsing, env var overrides, missing required field errors, I/O and parse error paths

Architecture notes#

FCIS pattern applied:

  • config.rsFunctional Core: Config, RawConfig, ConfigError, apply_env_overrides (takes explicit env HashMap), validate_and_build
  • config_loader.rsImperative Shell: load_config (reads file + real env), load_config_with_env (pub(crate) for test isolation)
  • relay/src/main.rsImperative Shell: CLI parsing, config loading, structured logging

Test plan#

  • cargo test --workspace — 13 tests pass, 0 failures
  • cargo clippy --workspace -- -D warnings — no warnings
  • cargo fmt --all --check — clean
  • cargo build --package relay — builds successfully
  • Manual smoke test: run ./target/debug/relay --config /nonexistent.toml and confirm error message is error: failed to load config from /nonexistent.toml: failed to read config file: No such file or directory (os error 2)
  • Manual smoke test: create a minimal relay.toml with data_dir and public_url, run ./target/debug/relay and confirm structured log line at startup

Closes MM-69

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:web:malpercio.dev/sh.tangled.repo.pull/3mglmncyxxf22
+101 -29
Interdiff #0 #1
Cargo.lock

This file has not been changed.

+3
Cargo.toml
··· 44 # Crypto (crypto) 45 # rsky-crypto = "0.2" 46 47 # Intra-workspace 48 common = { path = "crates/common" }
··· 44 # Crypto (crypto) 45 # rsky-crypto = "0.2" 46 47 + # Testing 48 + tempfile = "3" 49 + 50 # Intra-workspace 51 common = { path = "crates/common" }
+1 -1
crates/common/Cargo.toml
··· 12 thiserror = { workspace = true } 13 14 [dev-dependencies] 15 - tempfile = "3"
··· 12 thiserror = { workspace = true } 13 14 [dev-dependencies] 15 + tempfile = { workspace = true }
+52 -19
crates/common/src/config.rs
··· 47 48 #[derive(Debug, thiserror::Error)] 49 pub enum ConfigError { 50 - #[error("failed to read config file: {0}")] 51 - Io(#[from] std::io::Error), 52 #[error("failed to parse config file: {0}")] 53 Parse(#[from] toml::de::Error), 54 #[error("invalid configuration: missing required field '{field}'")] ··· 57 Invalid(String), 58 } 59 60 - /// Apply `EZPDS_*` environment variable overrides to a `RawConfig`. 61 /// 62 - /// Receives the environment as a map so this function stays pure (no `std::env` access). 63 pub(crate) fn apply_env_overrides( 64 - raw: &mut RawConfig, 65 env: &HashMap<String, String>, 66 - ) -> Result<(), ConfigError> { 67 if let Some(v) = env.get("EZPDS_BIND_ADDRESS") { 68 raw.bind_address = Some(v.clone()); 69 } 70 if let Some(v) = env.get("EZPDS_PORT") { 71 - raw.port = Some(v.parse::<u16>().map_err(|_| { 72 - ConfigError::Invalid(format!("EZPDS_PORT is not a valid port number: '{v}'")) 73 })?); 74 } 75 if let Some(v) = env.get("EZPDS_DATA_DIR") { ··· 81 if let Some(v) = env.get("EZPDS_PUBLIC_URL") { 82 raw.public_url = Some(v.clone()); 83 } 84 - Ok(()) 85 } 86 87 - /// Validate a `RawConfig` and build a `Config`, applying defaults for optional fields. 88 pub(crate) fn validate_and_build(raw: RawConfig) -> Result<Config, ConfigError> { 89 let bind_address = raw.bind_address.unwrap_or_else(|| "0.0.0.0".to_string()); 90 let port = raw.port.unwrap_or(8080); ··· 92 .data_dir 93 .ok_or(ConfigError::MissingField { field: "data_dir" })? 94 .into(); 95 - let database_url = raw 96 - .database_url 97 - .unwrap_or_else(|| data_dir.join("relay.db").to_string_lossy().into_owned()); 98 let public_url = raw.public_url.ok_or(ConfigError::MissingField { 99 field: "public_url", 100 })?; ··· 183 184 #[test] 185 fn env_override_port() { 186 - let mut raw = minimal_raw(); 187 let env = HashMap::from([("EZPDS_PORT".to_string(), "9090".to_string())]); 188 - apply_env_overrides(&mut raw, &env).unwrap(); 189 let config = validate_and_build(raw).unwrap(); 190 191 assert_eq!(config.port, 9090); 192 } 193 194 #[test] 195 fn env_override_all_fields() { 196 - let mut raw = RawConfig::default(); 197 let env = HashMap::from([ 198 ("EZPDS_BIND_ADDRESS".to_string(), "127.0.0.1".to_string()), 199 ("EZPDS_PORT".to_string(), "4000".to_string()), ··· 207 "https://pds.test".to_string(), 208 ), 209 ]); 210 - apply_env_overrides(&mut raw, &env).unwrap(); 211 let config = validate_and_build(raw).unwrap(); 212 213 assert_eq!(config.bind_address, "127.0.0.1"); ··· 219 220 #[test] 221 fn env_override_invalid_port_returns_error() { 222 - let mut raw = minimal_raw(); 223 let env = HashMap::from([("EZPDS_PORT".to_string(), "not_a_port".to_string())]); 224 - let err = apply_env_overrides(&mut raw, &env).unwrap_err(); 225 226 assert!(matches!(err, ConfigError::Invalid(_))); 227 assert!(err.to_string().contains("EZPDS_PORT")); 228 } 229 230 #[test]
··· 47 48 #[derive(Debug, thiserror::Error)] 49 pub enum ConfigError { 50 + #[error("failed to read config file {path}: {source}")] 51 + Io { 52 + path: PathBuf, 53 + #[source] 54 + source: std::io::Error, 55 + }, 56 #[error("failed to parse config file: {0}")] 57 Parse(#[from] toml::de::Error), 58 #[error("invalid configuration: missing required field '{field}'")] ··· 61 Invalid(String), 62 } 63 64 + /// Apply `EZPDS_*` environment variable overrides to a [`RawConfig`], returning the updated config. 65 /// 66 + /// Receives the environment as a map so this function stays isolated from I/O (no `std::env` 67 + /// access). Takes `raw` by value and returns it so callers can chain calls without mutation. 68 pub(crate) fn apply_env_overrides( 69 + mut raw: RawConfig, 70 env: &HashMap<String, String>, 71 + ) -> Result<RawConfig, ConfigError> { 72 if let Some(v) = env.get("EZPDS_BIND_ADDRESS") { 73 raw.bind_address = Some(v.clone()); 74 } 75 if let Some(v) = env.get("EZPDS_PORT") { 76 + raw.port = Some(v.parse::<u16>().map_err(|e| { 77 + ConfigError::Invalid(format!("EZPDS_PORT is not a valid port number: '{v}': {e}")) 78 })?); 79 } 80 if let Some(v) = env.get("EZPDS_DATA_DIR") { ··· 86 if let Some(v) = env.get("EZPDS_PUBLIC_URL") { 87 raw.public_url = Some(v.clone()); 88 } 89 + Ok(raw) 90 } 91 92 + /// Validate a [`RawConfig`] and build a [`Config`], applying defaults for optional fields. 93 + /// 94 + /// Required fields: `data_dir`, `public_url`. 95 + /// Defaults: `bind_address = "0.0.0.0"`, `port = 8080`, 96 + /// `database_url = "{data_dir}/relay.db"` (derived; fails if `data_dir` is non-UTF-8). 97 pub(crate) fn validate_and_build(raw: RawConfig) -> Result<Config, ConfigError> { 98 let bind_address = raw.bind_address.unwrap_or_else(|| "0.0.0.0".to_string()); 99 let port = raw.port.unwrap_or(8080); ··· 101 .data_dir 102 .ok_or(ConfigError::MissingField { field: "data_dir" })? 103 .into(); 104 + let database_url = match raw.database_url { 105 + Some(url) => url, 106 + None => data_dir 107 + .join("relay.db") 108 + .to_str() 109 + .ok_or_else(|| { 110 + ConfigError::Invalid( 111 + "data_dir contains non-UTF-8 characters, cannot derive database_url" 112 + .to_string(), 113 + ) 114 + })? 115 + .to_owned(), 116 + }; 117 let public_url = raw.public_url.ok_or(ConfigError::MissingField { 118 field: "public_url", 119 })?; ··· 202 203 #[test] 204 fn env_override_port() { 205 let env = HashMap::from([("EZPDS_PORT".to_string(), "9090".to_string())]); 206 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 207 let config = validate_and_build(raw).unwrap(); 208 209 assert_eq!(config.port, 9090); 210 } 211 212 #[test] 213 + fn env_override_wins_over_toml_value() { 214 + // env always takes precedence over explicit TOML values 215 + let toml = r#" 216 + data_dir = "/var/pds" 217 + port = 3000 218 + public_url = "https://pds.example.com" 219 + "#; 220 + let raw: RawConfig = toml::from_str(toml).unwrap(); 221 + let env = HashMap::from([("EZPDS_PORT".to_string(), "9999".to_string())]); 222 + let raw = apply_env_overrides(raw, &env).unwrap(); 223 + let config = validate_and_build(raw).unwrap(); 224 + 225 + assert_eq!(config.port, 9999); 226 + } 227 + 228 + #[test] 229 fn env_override_all_fields() { 230 let env = HashMap::from([ 231 ("EZPDS_BIND_ADDRESS".to_string(), "127.0.0.1".to_string()), 232 ("EZPDS_PORT".to_string(), "4000".to_string()), ··· 240 "https://pds.test".to_string(), 241 ), 242 ]); 243 + let raw = apply_env_overrides(RawConfig::default(), &env).unwrap(); 244 let config = validate_and_build(raw).unwrap(); 245 246 assert_eq!(config.bind_address, "127.0.0.1"); ··· 252 253 #[test] 254 fn env_override_invalid_port_returns_error() { 255 let env = HashMap::from([("EZPDS_PORT".to_string(), "not_a_port".to_string())]); 256 + let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 257 258 assert!(matches!(err, ConfigError::Invalid(_))); 259 assert!(err.to_string().contains("EZPDS_PORT")); 260 + assert!(err.to_string().contains("not_a_port")); 261 } 262 263 #[test]
+42 -7
crates/common/src/config_loader.rs
··· 5 6 use crate::config::{apply_env_overrides, validate_and_build, Config, ConfigError, RawConfig}; 7 8 /// Load [`Config`] from a TOML file with an explicit environment map. 9 /// 10 - /// Prefer [`load_config`] for production use. This variant is `pub(crate)` so 11 - /// tests can pass a controlled environment without leaking real `EZPDS_*` vars. 12 pub(crate) fn load_config_with_env( 13 path: &Path, 14 env: &HashMap<String, String>, 15 ) -> Result<Config, ConfigError> { 16 - let contents = std::fs::read_to_string(path)?; 17 - let mut raw: RawConfig = toml::from_str(&contents)?; 18 - apply_env_overrides(&mut raw, env)?; 19 validate_and_build(raw) 20 } 21 22 /// Load [`Config`] from a TOML file, applying `EZPDS_*` environment variable overrides. 23 pub fn load_config(path: &Path) -> Result<Config, ConfigError> { 24 - let env: HashMap<String, String> = std::env::vars().collect(); 25 load_config_with_env(path, &env) 26 } 27 ··· 52 } 53 54 #[test] 55 fn env_overrides_applied_from_file() { 56 let mut tmp = tempfile::NamedTempFile::new().unwrap(); 57 writeln!( ··· 71 fn returns_error_for_missing_file() { 72 let result = load_config_with_env(Path::new("/nonexistent/relay.toml"), &empty_env()); 73 74 - assert!(matches!(result, Err(ConfigError::Io(_)))); 75 } 76 77 #[test]
··· 5 6 use crate::config::{apply_env_overrides, validate_and_build, Config, ConfigError, RawConfig}; 7 8 + /// Collect only `EZPDS_*` env vars from the process environment, rejecting any with non-UTF-8 9 + /// values rather than panicking (as `std::env::vars()` would on non-UTF-8 data). 10 + fn collect_ezpds_env() -> Result<HashMap<String, String>, ConfigError> { 11 + let mut map = HashMap::new(); 12 + for (key_os, val_os) in std::env::vars_os() { 13 + let key = match key_os.to_str() { 14 + Some(k) if k.starts_with("EZPDS_") => k.to_owned(), 15 + _ => continue, 16 + }; 17 + let val = val_os.into_string().map_err(|_| { 18 + ConfigError::Invalid(format!( 19 + "environment variable {key} contains non-UTF-8 data" 20 + )) 21 + })?; 22 + map.insert(key, val); 23 + } 24 + Ok(map) 25 + } 26 + 27 /// Load [`Config`] from a TOML file with an explicit environment map. 28 /// 29 + /// Prefer [`load_config`] for production use. This variant is `pub(crate)` so tests can pass a 30 + /// controlled environment without leaking real `EZPDS_*` vars. 31 pub(crate) fn load_config_with_env( 32 path: &Path, 33 env: &HashMap<String, String>, 34 ) -> Result<Config, ConfigError> { 35 + let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { 36 + path: path.to_owned(), 37 + source, 38 + })?; 39 + let raw: RawConfig = toml::from_str(&contents)?; 40 + let raw = apply_env_overrides(raw, env)?; 41 validate_and_build(raw) 42 } 43 44 /// Load [`Config`] from a TOML file, applying `EZPDS_*` environment variable overrides. 45 pub fn load_config(path: &Path) -> Result<Config, ConfigError> { 46 + let env = collect_ezpds_env()?; 47 load_config_with_env(path, &env) 48 } 49 ··· 74 } 75 76 #[test] 77 + fn loads_minimal_valid_toml_produces_missing_field_error() { 78 + // An empty file is valid TOML but missing required fields. 79 + let tmp = tempfile::NamedTempFile::new().unwrap(); 80 + 81 + let err = load_config_with_env(tmp.path(), &empty_env()).unwrap_err(); 82 + 83 + assert!(matches!( 84 + err, 85 + ConfigError::MissingField { field: "data_dir" } 86 + )); 87 + } 88 + 89 + #[test] 90 fn env_overrides_applied_from_file() { 91 let mut tmp = tempfile::NamedTempFile::new().unwrap(); 92 writeln!( ··· 106 fn returns_error_for_missing_file() { 107 let result = load_config_with_env(Path::new("/nonexistent/relay.toml"), &empty_env()); 108 109 + assert!(matches!(result, Err(ConfigError::Io { .. }))); 110 } 111 112 #[test]
crates/common/src/lib.rs

This file has not been changed.

crates/relay/Cargo.toml

This file has not been changed.

+3 -2
crates/relay/src/main.rs
··· 7 #[derive(Parser)] 8 #[command(name = "relay", about = "ezpds relay server")] 9 struct Cli { 10 - /// Path to relay.toml config file (env: EZPDS_CONFIG) 11 #[arg(long, env = "EZPDS_CONFIG")] 12 config: Option<PathBuf>, 13 } ··· 22 fn run() -> anyhow::Result<()> { 23 tracing_subscriber::fmt() 24 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 25 - .init(); 26 27 let cli = Cli::parse(); 28 let config_path = cli.config.unwrap_or_else(|| PathBuf::from("relay.toml"));
··· 7 #[derive(Parser)] 8 #[command(name = "relay", about = "ezpds relay server")] 9 struct Cli { 10 + /// Path to relay.toml config file 11 #[arg(long, env = "EZPDS_CONFIG")] 12 config: Option<PathBuf>, 13 } ··· 22 fn run() -> anyhow::Result<()> { 23 tracing_subscriber::fmt() 24 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 25 + .try_init() 26 + .map_err(|e| anyhow::anyhow!("failed to initialize tracing subscriber: {e}"))?; 27 28 let cli = Cli::parse(); 29 let config_path = cli.config.unwrap_or_else(|| PathBuf::from("relay.toml"));

History

2 rounds 0 comments
sign up or login to add to the discussion
2 commits
expand
feat(MM-69): configuration system — relay.toml parsing
fix(MM-69): address PR review — UTF-8 safety, error context, pure core
expand 0 comments
pull request successfully merged
1 commit
expand
feat(MM-69): configuration system — relay.toml parsing
expand 0 comments