An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::config::{apply_env_overrides, validate_and_build, Config, ConfigError, RawConfig};
5
6/// Standard OpenTelemetry env vars we read in addition to our `EZPDS_*` prefix.
7const OTEL_ENV_KEYS: &[&str] = &["OTEL_SERVICE_NAME"];
8
9/// Collect `EZPDS_*` env vars and selected OTel standard vars from the process environment,
10/// rejecting any with non-UTF-8 values rather than panicking.
11fn collect_ezpds_env() -> Result<HashMap<String, String>, ConfigError> {
12 let mut map = HashMap::new();
13 for (key_os, val_os) in std::env::vars_os() {
14 let key = match key_os.to_str() {
15 Some(k) if k.starts_with("EZPDS_") || OTEL_ENV_KEYS.contains(&k) => k.to_owned(),
16 _ => continue,
17 };
18 let val = val_os.into_string().map_err(|_| {
19 ConfigError::Invalid(format!(
20 "environment variable {key} contains non-UTF-8 data"
21 ))
22 })?;
23 map.insert(key, val);
24 }
25 Ok(map)
26}
27
28/// Load [`Config`] from a TOML file with an explicit environment map.
29///
30/// Prefer [`load_config`] for production use. This variant is `pub(crate)` so tests can pass a
31/// controlled environment without leaking real `EZPDS_*` vars.
32pub(crate) fn load_config_with_env(
33 path: &Path,
34 env: &HashMap<String, String>,
35) -> Result<Config, ConfigError> {
36 let contents = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
37 path: path.to_owned(),
38 source,
39 })?;
40 let raw: RawConfig = toml::from_str(&contents)?;
41 let raw = apply_env_overrides(raw, env)?;
42 validate_and_build(raw)
43}
44
45/// Load [`Config`] from a TOML file, applying `EZPDS_*` environment variable overrides.
46pub fn load_config(path: &Path) -> Result<Config, ConfigError> {
47 let env = collect_ezpds_env()?;
48 load_config_with_env(path, &env)
49}
50
51#[cfg(test)]
52mod tests {
53 use super::*;
54 use std::io::Write;
55
56 fn empty_env() -> HashMap<String, String> {
57 HashMap::new()
58 }
59
60 #[test]
61 fn loads_config_from_file() {
62 let mut tmp = tempfile::NamedTempFile::new().unwrap();
63 writeln!(
64 tmp,
65 r#"data_dir = "/var/pds"
66public_url = "https://pds.example.com"
67available_user_domains = ["example.com"]"#
68 )
69 .unwrap();
70
71 let config = load_config_with_env(tmp.path(), &empty_env()).unwrap();
72
73 assert_eq!(config.public_url, "https://pds.example.com");
74 assert_eq!(config.bind_address, "0.0.0.0");
75 assert_eq!(config.port, 8080);
76 }
77
78 #[test]
79 fn loads_minimal_valid_toml_produces_missing_field_error() {
80 // An empty file is valid TOML but missing required fields.
81 let tmp = tempfile::NamedTempFile::new().unwrap();
82
83 let err = load_config_with_env(tmp.path(), &empty_env()).unwrap_err();
84
85 assert!(matches!(
86 err,
87 ConfigError::MissingField { field: "data_dir" }
88 ));
89 }
90
91 #[test]
92 fn env_overrides_applied_from_file() {
93 let mut tmp = tempfile::NamedTempFile::new().unwrap();
94 writeln!(
95 tmp,
96 r#"data_dir = "/var/pds"
97public_url = "https://pds.example.com"
98available_user_domains = ["example.com"]"#
99 )
100 .unwrap();
101 let env = HashMap::from([("EZPDS_PORT".to_string(), "9999".to_string())]);
102
103 let config = load_config_with_env(tmp.path(), &env).unwrap();
104
105 assert_eq!(config.port, 9999);
106 }
107
108 #[test]
109 fn returns_error_for_missing_file() {
110 let result = load_config_with_env(Path::new("/nonexistent/relay.toml"), &empty_env());
111
112 assert!(matches!(result, Err(ConfigError::Io { .. })));
113 }
114
115 #[test]
116 fn returns_error_for_invalid_toml() {
117 let mut tmp = tempfile::NamedTempFile::new().unwrap();
118 writeln!(tmp, "not valid toml = [[[").unwrap();
119
120 let result = load_config_with_env(tmp.path(), &empty_env());
121
122 assert!(matches!(result, Err(ConfigError::Parse(_))));
123 }
124}