Skip to main content

cratefield_core/
config.rs

1//! Typed configuration: the [`Config`] trait and the error type that
2//! `Harness::build` uses to report every problem at once (issue #2), plus
3//! the [`ModuleConfig`] helper modules read keys through (issue #3).
4//!
5//! Keys are `SCREAMING_SNAKE`; module keys are prefixed with the module
6//! name, e.g. `EMAIL_SIGNUP_CONFIRM_TTL_DAYS`.
7
8use std::fmt;
9
10use crate::signer::{HmacSigner, MIN_SECRET_BYTES};
11use crate::venture::VentureEnv;
12
13/// Read-only key/value configuration, resolved per runtime from environment
14/// variables and secrets (Workers `Env`) or the process environment.
15///
16/// Keys are `SCREAMING_SNAKE`; module keys are prefixed with the module name,
17/// e.g. `EMAIL_SIGNUP_CONFIRM_TTL_DAYS`.
18pub trait Config: Send + Sync {
19    fn get(&self, key: &str) -> Option<String>;
20}
21
22/// A configuration that always returns `None` (tests, offline builds).
23#[derive(Debug, Default, Clone, Copy)]
24pub struct EmptyConfig;
25
26impl Config for EmptyConfig {
27    fn get(&self, _key: &str) -> Option<String> {
28        None
29    }
30}
31
32/// Accumulates every configuration problem so `Harness::build` can report
33/// them together instead of one at a time.
34#[derive(Debug, Default, Clone)]
35pub struct ConfigError {
36    /// Human-readable problem descriptions, one per line of output.
37    pub problems: Vec<String>,
38}
39
40impl ConfigError {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    pub fn push(&mut self, problem: impl Into<String>) {
46        self.problems.push(problem.into());
47    }
48
49    pub fn is_empty(&self) -> bool {
50        self.problems.is_empty()
51    }
52
53    /// `Err(self)` when any problem was recorded.
54    ///
55    /// # Errors
56    ///
57    /// `Err` with every recorded problem joined in its `Display`.
58    pub fn into_result(self) -> Result<(), Self> {
59        if self.is_empty() { Ok(()) } else { Err(self) }
60    }
61}
62
63impl fmt::Display for ConfigError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "invalid harness configuration:")?;
66        for problem in &self.problems {
67            write!(f, "\n  - {problem}")?;
68        }
69        Ok(())
70    }
71}
72
73impl std::error::Error for ConfigError {}
74
75/// Typed view over a [`Config`] for one module: prefixes every key with the
76/// module name in `SCREAMING_SNAKE` and parses values with defaults
77/// (issue #3).
78///
79/// ```
80/// use cratefield_core::{Config, ModuleConfig};
81/// # struct MapConfig(std::collections::HashMap<String, String>);
82/// # impl Config for MapConfig {
83/// #     fn get(&self, key: &str) -> Option<String> {
84/// #         self.0.get(key).cloned()
85/// #     }
86/// # }
87/// let cfg = MapConfig(
88///     [("EMAIL_SIGNUP_CONFIRM_TTL_DAYS".to_string(), "3".to_string())]
89///         .into_iter()
90///         .collect(),
91/// );
92/// let module = ModuleConfig::new("email-signup", &cfg);
93/// assert_eq!(module.get_u32("CONFIRM_TTL_DAYS", 7), 3);
94/// assert_eq!(module.get_bool("DOUBLE_OPT_IN", true), true);
95/// assert_eq!(module.get_str("FROM_NAME", "Factory Zero"), "Factory Zero");
96/// ```
97pub struct ModuleConfig<'a> {
98    prefix: String,
99    config: &'a dyn Config,
100}
101
102fn screaming_snake(name: &str) -> String {
103    let mut out = String::with_capacity(name.len() + 8);
104    for ch in name.chars() {
105        if ch == '-' || ch == '_' {
106            out.push('_');
107        } else {
108            out.extend(ch.to_uppercase());
109        }
110    }
111    out
112}
113
114impl<'a> ModuleConfig<'a> {
115    pub fn new(module_name: &str, config: &'a dyn Config) -> Self {
116        Self {
117            prefix: screaming_snake(module_name),
118            config,
119        }
120    }
121
122    /// The fully-qualified key for a module-suffix key.
123    pub fn key(&self, suffix: &str) -> String {
124        format!("{}_{}", self.prefix, screaming_snake(suffix))
125    }
126
127    pub fn get_str(&self, key_suffix: &str, default: &str) -> String {
128        self.config
129            .get(&self.key(key_suffix))
130            .unwrap_or_else(|| default.to_string())
131    }
132
133    pub fn get_u32(&self, key_suffix: &str, default: u32) -> u32 {
134        self.config
135            .get(&self.key(key_suffix))
136            .and_then(|raw| raw.parse().ok())
137            .unwrap_or(default)
138    }
139
140    pub fn get_bool(&self, key_suffix: &str, default: bool) -> bool {
141        match self.config.get(&self.key(key_suffix)) {
142            Some(raw) => matches!(
143                raw.to_ascii_lowercase().as_str(),
144                "1" | "true" | "yes" | "on"
145            ),
146            None => default,
147        }
148    }
149
150    /// An explicitly-set string key, `None` when absent.
151    pub fn get_opt(&self, key_suffix: &str) -> Option<String> {
152        self.config.get(&self.key(key_suffix))
153    }
154}
155
156/// The harness-level keys, parsed once from the environment `Config`
157/// (issue #3): `HARNESS_SECRET` (required, ≥ 32 bytes),
158/// `HARNESS_SECRET_PREVIOUS` (optional), `ADMIN_TOKEN` (optional),
159/// `ENV` (`development|staging|production`, default `development`).
160///
161/// Dummy secrets only, in tests:
162/// `HARNESS_SECRET = "test-secret-0123456789abcdef-0123"`.
163#[derive(Debug, Clone)]
164pub struct HarnessConfig {
165    pub harness_secret: String,
166    pub harness_secret_previous: Option<String>,
167    pub admin_token: Option<String>,
168    pub env: VentureEnv,
169}
170
171impl HarnessConfig {
172    /// # Errors
173    ///
174    /// One problem per invalid key, reported together: missing or short
175    /// `HARNESS_SECRET`, unknown `ENV` value.
176    pub fn from_config(config: &dyn Config) -> Result<Self, ConfigError> {
177        let mut errors = ConfigError::default();
178
179        let harness_secret = match config.get("HARNESS_SECRET") {
180            Some(secret) if secret.len() >= MIN_SECRET_BYTES => Some(secret),
181            Some(_) => {
182                errors.push(format!(
183                    "HARNESS_SECRET must be at least {MIN_SECRET_BYTES} bytes"
184                ));
185                None
186            }
187            None => {
188                errors.push(format!(
189                    "HARNESS_SECRET is required (min {MIN_SECRET_BYTES} bytes)"
190                ));
191                None
192            }
193        };
194        let env = match config.get("ENV").as_deref() {
195            None | Some("") => Some(VentureEnv::Development),
196            Some(raw) => {
197                let parsed = VentureEnv::parse(raw);
198                if parsed.is_none() {
199                    errors.push(format!(
200                        "ENV must be one of development|staging|production, got {raw:?}"
201                    ));
202                }
203                parsed
204            }
205        };
206
207        errors.into_result()?;
208        Ok(Self {
209            harness_secret: harness_secret.unwrap_or_default(),
210            harness_secret_previous: config.get("HARNESS_SECRET_PREVIOUS"),
211            admin_token: config.get("ADMIN_TOKEN"),
212            env: env.unwrap_or_default(),
213        })
214    }
215
216    /// The HMAC signer for this configuration (ADR 0006).
217    ///
218    /// # Panics
219    ///
220    /// Only when `from_config` was bypassed with an invalid secret.
221    pub fn signer(&self) -> HmacSigner {
222        HmacSigner::new(
223            self.harness_secret.clone(),
224            self.harness_secret_previous.clone(),
225        )
226        .expect("from_config validated the secret")
227    }
228}
229
230/// A `Config` backed by a map (tests, `fz doctor` with process env).
231#[derive(Debug, Clone, Default)]
232pub struct MapConfig(pub std::collections::HashMap<String, String>);
233
234impl MapConfig {
235    pub fn from_pairs(
236        pairs: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
237    ) -> Self {
238        Self(
239            pairs
240                .into_iter()
241                .map(|(k, v)| (k.into(), v.into()))
242                .collect(),
243        )
244    }
245}
246
247impl Config for MapConfig {
248    fn get(&self, key: &str) -> Option<String> {
249        self.0.get(key).cloned()
250    }
251}