Skip to main content

baukit_config/
lib.rs

1//! Layered, validated configuration conventions for Baukit services.
2//!
3//! [`ConfigLoader`] applies configuration in this order: serde-backed defaults,
4//! an optional local file, then environment variables. Environment variables use
5//! an application prefix and a double-underscore separator, such as
6//! `MYAPP__HTTP__PORT=8080`. A local `.env` file is loaded only when the loader's
7//! deployment [`Environment`] is [`Environment::Local`].
8//! Collection-valued environment variables use JSON array syntax, for example
9//! `MYAPP__HTTP__CORS_ALLOWED_ORIGINS=["https://app.example.com"]`. Scalar
10//! environment values retain their exact source strings, including values that
11//! look numeric, so secret-string preservation is unchanged.
12//! `http.cors_allowed_origins` is registered automatically; product collection
13//! fields must be declared with [`ConfigLoader::environment_collection`].
14//!
15//! Durations in the standard sections are represented as [`Duration`] values and
16//! deserialize from integer seconds.
17//!
18//! Shared environment and log-format vocabulary lives in the dependency-light
19//! `baukit-core` crate and is re-exported here. This lets configuration and
20//! telemetry use identical types without making configuration depend on the
21//! OpenTelemetry exporter stack.
22//!
23//! # Example
24//!
25//! ```no_run
26//! use baukit_config::{BaukitConfig, ConfigLoader, Environment, Validate, ValidationErrors};
27//! use serde::Deserialize;
28//!
29//! #[derive(Debug, Default, Deserialize)]
30//! #[serde(default)]
31//! struct ProductConfig {
32//!     feature_name: Option<String>,
33//! }
34//!
35//! impl Validate for ProductConfig {
36//!     fn validate(&self) -> Result<(), ValidationErrors> {
37//!         Ok(())
38//!     }
39//! }
40//!
41//! let config: BaukitConfig<ProductConfig> =
42//!     ConfigLoader::new("myapp", Environment::Local)?.load()?;
43//! # Ok::<(), baukit_config::LoadError>(())
44//! ```
45
46#![deny(missing_docs)]
47
48use std::{
49    collections::BTreeSet,
50    fmt,
51    net::{IpAddr, Ipv4Addr},
52    path::PathBuf,
53    time::Duration,
54};
55
56pub use baukit_core::{
57    DeploymentEnvironment, DeploymentEnvironment as Environment, LogFormat, ParseEnvironmentError,
58};
59use config::{Config, File, Map as ConfigMap, Source, Value, ValueKind};
60use serde::{Deserialize, Deserializer};
61use thiserror::Error;
62use zeroize::Zeroize;
63
64const DEFAULT_LOCAL_FILE: &str = "config/local.toml";
65const DEFAULT_DOTENV_FILE: &str = ".env";
66const REDACTED: &str = "[redacted]";
67
68/// A secret value whose formatted representations never reveal its contents.
69///
70/// The inner value is zeroized when the wrapper is dropped. Access is deliberately
71/// named [`Secret::expose`] so reviews can find places where secrets are revealed.
72pub struct Secret<T: Zeroize>(T);
73
74impl<T: Zeroize> Secret<T> {
75    /// Wraps a value as a secret.
76    pub fn new(value: T) -> Self {
77        Self(value)
78    }
79
80    /// Deliberately exposes a shared reference to the secret value.
81    pub fn expose(&self) -> &T {
82        &self.0
83    }
84}
85
86impl<T> Clone for Secret<T>
87where
88    T: Clone + Zeroize,
89{
90    fn clone(&self) -> Self {
91        Self(self.0.clone())
92    }
93}
94
95impl<T: Zeroize> fmt::Debug for Secret<T> {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        formatter.write_str(REDACTED)
98    }
99}
100
101impl<T: Zeroize> fmt::Display for Secret<T> {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter.write_str(REDACTED)
104    }
105}
106
107impl<T: Zeroize> Drop for Secret<T> {
108    fn drop(&mut self) {
109        self.0.zeroize();
110    }
111}
112
113impl<'de, T> Deserialize<'de> for Secret<T>
114where
115    T: Deserialize<'de> + Zeroize,
116{
117    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118    where
119        D: Deserializer<'de>,
120    {
121        T::deserialize(deserializer).map(Self)
122    }
123}
124
125/// Configuration for the public HTTP listener.
126#[derive(Clone, Debug, Deserialize)]
127#[serde(default)]
128pub struct HttpConfig {
129    /// Address on which the public listener accepts connections.
130    pub bind_address: IpAddr,
131    /// TCP port for the public listener.
132    pub port: u16,
133    /// Maximum time allowed for an HTTP request, in integer seconds on input.
134    #[serde(with = "duration_seconds")]
135    pub request_timeout: Duration,
136    /// Maximum accepted request-body size in bytes.
137    pub body_size_limit: usize,
138    /// Maximum number of requests processed concurrently.
139    pub concurrency_limit: usize,
140    /// Origins allowed by CORS. An empty list allows no cross-origin requests.
141    pub cors_allowed_origins: Vec<String>,
142}
143
144impl Default for HttpConfig {
145    fn default() -> Self {
146        Self {
147            bind_address: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
148            port: 8080,
149            request_timeout: Duration::from_secs(30),
150            body_size_limit: 2 * 1024 * 1024,
151            concurrency_limit: 1_024,
152            cors_allowed_origins: Vec::new(),
153        }
154    }
155}
156
157impl Validate for HttpConfig {
158    fn validate(&self) -> Result<(), ValidationErrors> {
159        let mut errors = Vec::new();
160        require_non_zero(self.port, "port", &mut errors);
161        require_non_zero(
162            self.request_timeout.as_secs(),
163            "request_timeout",
164            &mut errors,
165        );
166        require_non_zero(self.body_size_limit, "body_size_limit", &mut errors);
167        require_non_zero(self.concurrency_limit, "concurrency_limit", &mut errors);
168        for (index, origin) in self.cors_allowed_origins.iter().enumerate() {
169            if origin.trim().is_empty() {
170                errors.push(ValidationError::new(
171                    format!("cors_allowed_origins[{index}]"),
172                    "must not be empty",
173                ));
174            }
175        }
176        validation_result(errors)
177    }
178}
179
180/// Whether request processing continues when the rate-limit store is unavailable.
181#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
182#[serde(rename_all = "snake_case")]
183pub enum RateLimitFailMode {
184    /// Record the store error and allow the request to continue.
185    #[default]
186    Open,
187    /// Record the store error and reject the request.
188    Closed,
189}
190
191/// Configuration for one token-bucket rate-limit scope.
192#[derive(Clone, Debug, Deserialize)]
193#[serde(default)]
194pub struct RateLimitScopeConfig {
195    /// Whether this scope is evaluated.
196    pub enabled: bool,
197    /// Tokens refilled during each period.
198    pub requests_per_period: u64,
199    /// Refill period, in integer seconds on input.
200    #[serde(with = "duration_seconds")]
201    pub period: Duration,
202    /// Extra tokens available above the steady-period request count.
203    pub burst: u64,
204}
205
206impl RateLimitScopeConfig {
207    fn identity_default() -> Self {
208        Self {
209            enabled: true,
210            requests_per_period: 60,
211            period: Duration::from_secs(60),
212            burst: 10,
213        }
214    }
215
216    fn ip_default() -> Self {
217        Self {
218            enabled: true,
219            requests_per_period: 6_000,
220            period: Duration::from_secs(60),
221            burst: 500,
222        }
223    }
224
225    fn validate_into(&self, path: &str, errors: &mut Vec<ValidationError>) {
226        if !self.enabled {
227            return;
228        }
229        require_non_zero(
230            self.requests_per_period,
231            &format!("{path}.requests_per_period"),
232            errors,
233        );
234        require_non_zero(self.period.as_nanos(), &format!("{path}.period"), errors);
235        if self.requests_per_period.checked_add(self.burst).is_none() {
236            errors.push(ValidationError::new(
237                format!("{path}.burst"),
238                "must not overflow the bucket capacity",
239            ));
240        }
241    }
242}
243
244impl Default for RateLimitScopeConfig {
245    fn default() -> Self {
246        Self::identity_default()
247    }
248}
249
250/// Shared Redis-backed identity and client-IP rate-limit configuration.
251///
252/// Environment overrides use the standard nested naming convention, for
253/// example `<PREFIX>__RATE_LIMIT__REDIS_URL` and
254/// `<PREFIX>__RATE_LIMIT__IDENTITY__REQUESTS_PER_PERIOD`.
255#[derive(Clone, Debug, Deserialize)]
256#[serde(default)]
257pub struct RateLimitConfig {
258    /// Redis connection URL. Debug and display formatting redact its value.
259    pub redis_url: Secret<String>,
260    /// Authenticated-principal scope, intentionally lower than the IP net.
261    pub identity: RateLimitScopeConfig,
262    /// Client-IP safety-net scope.
263    #[serde(
264        default = "RateLimitScopeConfig::ip_default",
265        deserialize_with = "deserialize_ip_rate_limit_scope"
266    )]
267    pub ip: RateLimitScopeConfig,
268    /// Behavior when the backing store cannot make a decision.
269    pub fail_mode: RateLimitFailMode,
270    /// Number of trusted reverse-proxy hops, including the socket peer.
271    pub trusted_proxy_hops: usize,
272    /// Prefix prepended to all Redis and in-memory keys.
273    pub key_prefix: String,
274}
275
276fn deserialize_ip_rate_limit_scope<'de, D>(
277    deserializer: D,
278) -> Result<RateLimitScopeConfig, D::Error>
279where
280    D: Deserializer<'de>,
281{
282    #[derive(Deserialize)]
283    struct PartialScope {
284        enabled: Option<bool>,
285        requests_per_period: Option<u64>,
286        period: Option<u64>,
287        burst: Option<u64>,
288    }
289
290    let partial = PartialScope::deserialize(deserializer)?;
291    let defaults = RateLimitScopeConfig::ip_default();
292    Ok(RateLimitScopeConfig {
293        enabled: partial.enabled.unwrap_or(defaults.enabled),
294        requests_per_period: partial
295            .requests_per_period
296            .unwrap_or(defaults.requests_per_period),
297        period: partial.period.map_or(defaults.period, Duration::from_secs),
298        burst: partial.burst.unwrap_or(defaults.burst),
299    })
300}
301
302impl Default for RateLimitConfig {
303    fn default() -> Self {
304        Self {
305            redis_url: Secret::new("redis://127.0.0.1/".to_owned()),
306            identity: RateLimitScopeConfig::identity_default(),
307            ip: RateLimitScopeConfig::ip_default(),
308            fail_mode: RateLimitFailMode::Open,
309            trusted_proxy_hops: 1,
310            key_prefix: "rl:".to_owned(),
311        }
312    }
313}
314
315impl Validate for RateLimitConfig {
316    fn validate(&self) -> Result<(), ValidationErrors> {
317        let mut errors = Vec::new();
318        self.identity.validate_into("identity", &mut errors);
319        self.ip.validate_into("ip", &mut errors);
320        if self.redis_url.expose().trim().is_empty() {
321            errors.push(ValidationError::new("redis_url", "must not be empty"));
322        }
323        if self.key_prefix.trim().is_empty() {
324            errors.push(ValidationError::new("key_prefix", "must not be empty"));
325        }
326        if self.trusted_proxy_hops > 32 {
327            errors.push(ValidationError::new(
328                "trusted_proxy_hops",
329                "must be at most 32",
330            ));
331        }
332        validation_result(errors)
333    }
334}
335
336/// Configuration for the separate operations listener.
337#[derive(Clone, Debug, Deserialize)]
338#[serde(default)]
339pub struct OpsConfig {
340    /// Address on which health, readiness, and metrics endpoints listen.
341    pub bind_address: IpAddr,
342    /// TCP port for the operations listener.
343    pub port: u16,
344}
345
346impl Default for OpsConfig {
347    fn default() -> Self {
348        Self {
349            bind_address: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
350            port: 9090,
351        }
352    }
353}
354
355impl Validate for OpsConfig {
356    fn validate(&self) -> Result<(), ValidationErrors> {
357        let mut errors = Vec::new();
358        require_non_zero(self.port, "port", &mut errors);
359        validation_result(errors)
360    }
361}
362
363/// Plain database connection and pool configuration.
364#[derive(Clone, Debug, Deserialize)]
365#[serde(default)]
366pub struct DatabaseConfig {
367    /// Database connection URL. Formatting the wrapper always redacts it.
368    pub url: Secret<String>,
369    /// Maximum number of connections in the pool.
370    pub max_connections: u32,
371    /// Minimum number of connections retained by the pool.
372    pub min_connections: u32,
373    /// Maximum time to wait for a connection, in integer seconds on input.
374    #[serde(with = "duration_seconds")]
375    pub acquire_timeout: Duration,
376}
377
378impl Default for DatabaseConfig {
379    fn default() -> Self {
380        Self {
381            url: Secret::new("postgres://localhost/app".to_owned()),
382            max_connections: 10,
383            min_connections: 1,
384            acquire_timeout: Duration::from_secs(5),
385        }
386    }
387}
388
389impl Validate for DatabaseConfig {
390    fn validate(&self) -> Result<(), ValidationErrors> {
391        let mut errors = Vec::new();
392        if self.url.expose().trim().is_empty() {
393            errors.push(ValidationError::new("url", "must not be empty"));
394        }
395        require_non_zero(self.max_connections, "max_connections", &mut errors);
396        if self.min_connections > self.max_connections {
397            errors.push(ValidationError::new(
398                "min_connections",
399                "must not exceed max_connections",
400            ));
401        }
402        require_non_zero(
403            self.acquire_timeout.as_secs(),
404            "acquire_timeout",
405            &mut errors,
406        );
407        validation_result(errors)
408    }
409}
410
411/// Resource identity values consumed by telemetry initialization.
412///
413/// Deployment environment is carried by [`BaukitConfig::environment`]. Fields
414/// remain optional because version and commit are commonly injected from build
415/// metadata rather than a configuration file.
416#[derive(Clone, Debug, Default, Deserialize)]
417#[serde(default)]
418pub struct TelemetryResourceConfig {
419    /// OpenTelemetry `service.name`, conventionally `<product>-<process>`.
420    pub service_name: Option<String>,
421    /// OpenTelemetry `service.version`, normally the Cargo package version.
422    pub service_version: Option<String>,
423    /// Baukit `service.commit`, normally a short build-time Git SHA.
424    pub service_commit: Option<String>,
425    /// Stable product identity shared by all of the product's processes.
426    pub product: Option<String>,
427}
428
429impl Validate for TelemetryResourceConfig {
430    fn validate(&self) -> Result<(), ValidationErrors> {
431        let mut errors = Vec::new();
432        validate_optional_text(&self.service_name, "service_name", &mut errors);
433        validate_optional_text(&self.service_version, "service_version", &mut errors);
434        validate_optional_text(&self.service_commit, "service_commit", &mut errors);
435        validate_optional_text(&self.product, "product", &mut errors);
436        validation_result(errors)
437    }
438}
439
440/// Configuration for traces and log rendering.
441#[derive(Clone, Debug, Deserialize)]
442#[serde(default)]
443pub struct TelemetryConfig {
444    /// OTLP endpoint, defaulting from `OTEL_EXPORTER_OTLP_ENDPOINT` when set.
445    pub otlp_endpoint: Option<String>,
446    /// Trace sampling ratio in the inclusive range `0.0..=1.0`.
447    pub trace_sampling_ratio: f64,
448    /// Optional log rendering override.
449    pub log_format: LogFormat,
450    /// Standard telemetry resource identity attributes.
451    pub resource: TelemetryResourceConfig,
452}
453
454impl Default for TelemetryConfig {
455    fn default() -> Self {
456        Self {
457            otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
458            trace_sampling_ratio: 1.0,
459            log_format: LogFormat::Auto,
460            resource: TelemetryResourceConfig::default(),
461        }
462    }
463}
464
465impl Validate for TelemetryConfig {
466    fn validate(&self) -> Result<(), ValidationErrors> {
467        let mut errors = Vec::new();
468        validate_optional_text(&self.otlp_endpoint, "otlp_endpoint", &mut errors);
469        if !self.trace_sampling_ratio.is_finite()
470            || !(0.0..=1.0).contains(&self.trace_sampling_ratio)
471        {
472            errors.push(ValidationError::new(
473                "trace_sampling_ratio",
474                "must be a finite value between 0.0 and 1.0",
475            ));
476        }
477        extend_validation(&mut errors, self.resource.validate(), Some("resource"));
478        validation_result(errors)
479    }
480}
481
482/// Configuration for graceful shutdown.
483#[derive(Clone, Debug, Deserialize)]
484#[serde(default)]
485pub struct ShutdownConfig {
486    /// Maximum time allowed for draining work, in integer seconds on input.
487    #[serde(with = "duration_seconds")]
488    pub drain_timeout: Duration,
489}
490
491impl Default for ShutdownConfig {
492    fn default() -> Self {
493        Self {
494            drain_timeout: Duration::from_secs(30),
495        }
496    }
497}
498
499impl Validate for ShutdownConfig {
500    fn validate(&self) -> Result<(), ValidationErrors> {
501        let mut errors = Vec::new();
502        require_non_zero(self.drain_timeout.as_secs(), "drain_timeout", &mut errors);
503        validation_result(errors)
504    }
505}
506
507/// Standard Baukit sections plus flattened, product-specific configuration.
508///
509/// Product fields live at the top level beside `http`, `ops`, optional `database`,
510/// `rate_limit`, `telemetry`, and `shutdown`, while validation errors are prefixed with
511/// `product.` to distinguish their ownership. Product types should use
512/// `#[serde(default)]` when their default field values should apply to omitted
513/// configuration keys.
514#[derive(Clone, Debug, Deserialize)]
515#[serde(default)]
516pub struct BaukitConfig<T: Default> {
517    /// Deployment environment fixed by [`ConfigLoader`] at bootstrap.
518    pub environment: Environment,
519    /// Public HTTP listener settings.
520    pub http: HttpConfig,
521    /// Separate operations listener settings.
522    pub ops: OpsConfig,
523    /// Optional database connection and pool settings.
524    ///
525    /// Omit the section for database-free services. When present, omitted
526    /// fields inside the section retain [`DatabaseConfig`] defaults.
527    pub database: Option<DatabaseConfig>,
528    /// Redis-backed identity and client-IP rate limiting.
529    pub rate_limit: RateLimitConfig,
530    /// Logging, tracing, and telemetry identity settings.
531    pub telemetry: TelemetryConfig,
532    /// Graceful shutdown settings.
533    pub shutdown: ShutdownConfig,
534    /// Product-owned extension fields, flattened into the top-level document.
535    #[serde(flatten)]
536    pub product: T,
537}
538
539impl<T: Default> Default for BaukitConfig<T> {
540    fn default() -> Self {
541        Self {
542            environment: Environment::default(),
543            http: HttpConfig::default(),
544            ops: OpsConfig::default(),
545            database: None,
546            rate_limit: RateLimitConfig::default(),
547            telemetry: TelemetryConfig::default(),
548            shutdown: ShutdownConfig::default(),
549            product: T::default(),
550        }
551    }
552}
553
554#[derive(Default, Deserialize)]
555#[serde(default)]
556struct StandardConfig {
557    environment: Environment,
558    http: HttpConfig,
559    ops: OpsConfig,
560    database: Option<DatabaseConfig>,
561    rate_limit: RateLimitConfig,
562    telemetry: TelemetryConfig,
563    shutdown: ShutdownConfig,
564}
565
566impl<T> Validate for BaukitConfig<T>
567where
568    T: Default + Validate,
569{
570    fn validate(&self) -> Result<(), ValidationErrors> {
571        let mut errors = Vec::new();
572        extend_validation(&mut errors, self.http.validate(), Some("http"));
573        extend_validation(&mut errors, self.ops.validate(), Some("ops"));
574        if let Some(database) = &self.database {
575            extend_validation(&mut errors, database.validate(), Some("database"));
576        }
577        extend_validation(&mut errors, self.rate_limit.validate(), Some("rate_limit"));
578        extend_validation(&mut errors, self.telemetry.validate(), Some("telemetry"));
579        extend_validation(&mut errors, self.shutdown.validate(), Some("shutdown"));
580        extend_validation(&mut errors, self.product.validate(), Some("product"));
581        validation_result(errors)
582    }
583}
584
585/// A type that checks its configuration invariants before startup.
586pub trait Validate {
587    /// Returns every validation problem found, rather than stopping at the first.
588    fn validate(&self) -> Result<(), ValidationErrors>;
589}
590
591impl Validate for () {
592    fn validate(&self) -> Result<(), ValidationErrors> {
593        Ok(())
594    }
595}
596
597/// One actionable configuration validation problem.
598#[derive(Clone, Debug, Error, Eq, PartialEq)]
599#[error("{path}: {message}")]
600pub struct ValidationError {
601    path: String,
602    message: String,
603}
604
605impl ValidationError {
606    /// Creates an error for a field path and an actionable message.
607    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
608        Self {
609            path: path.into(),
610            message: message.into(),
611        }
612    }
613
614    /// Returns the field path associated with this error.
615    pub fn path(&self) -> &str {
616        &self.path
617    }
618
619    /// Returns the actionable message associated with this error.
620    pub fn message(&self) -> &str {
621        &self.message
622    }
623
624    fn with_prefix(mut self, prefix: &str) -> Self {
625        self.path = if self.path.is_empty() {
626            prefix.to_owned()
627        } else {
628            format!("{prefix}.{}", self.path)
629        };
630        self
631    }
632}
633
634/// An aggregate of all configuration validation problems.
635#[derive(Clone, Debug, Error, Eq, PartialEq)]
636#[error("{rendered}")]
637pub struct ValidationErrors {
638    errors: Vec<ValidationError>,
639    rendered: String,
640}
641
642impl ValidationErrors {
643    /// Creates an aggregate from validation problems.
644    ///
645    /// Callers should return `Ok(())` instead when `errors` is empty.
646    pub fn new(errors: Vec<ValidationError>) -> Self {
647        let mut rendered = format!(
648            "configuration validation failed ({} error(s))",
649            errors.len()
650        );
651        for error in &errors {
652            rendered.push_str("\n- ");
653            rendered.push_str(&error.to_string());
654        }
655        Self { errors, rendered }
656    }
657
658    /// Returns all collected validation problems.
659    pub fn errors(&self) -> &[ValidationError] {
660        &self.errors
661    }
662
663    /// Consumes the aggregate and returns its individual problems.
664    pub fn into_errors(self) -> Vec<ValidationError> {
665        self.errors
666    }
667}
668
669/// Loads a [`BaukitConfig`] using the Baukit precedence and naming conventions.
670#[derive(Clone, Debug)]
671pub struct ConfigLoader {
672    prefix: String,
673    environment: Environment,
674    local_file: Option<PathBuf>,
675    dotenv_file: Option<PathBuf>,
676    environment_collections: BTreeSet<String>,
677}
678
679impl ConfigLoader {
680    /// Creates a loader for an application and deployment environment.
681    ///
682    /// ASCII letters are uppercased and `-` becomes `_`, so `my-app` uses the
683    /// `MY_APP__` prefix. Other characters are rejected.
684    pub fn new(app_name: &str, environment: Environment) -> Result<Self, LoadError> {
685        let prefix = normalize_prefix(app_name)?;
686        Ok(Self {
687            prefix,
688            environment,
689            local_file: Some(PathBuf::from(DEFAULT_LOCAL_FILE)),
690            dotenv_file: Some(PathBuf::from(DEFAULT_DOTENV_FILE)),
691            environment_collections: BTreeSet::from(["http.cors_allowed_origins".to_owned()]),
692        })
693    }
694
695    /// Replaces the optional local configuration-file path.
696    #[must_use]
697    pub fn local_file(mut self, path: impl Into<PathBuf>) -> Self {
698        self.local_file = Some(path.into());
699        self
700    }
701
702    /// Disables the local configuration-file layer.
703    #[must_use]
704    pub fn without_local_file(mut self) -> Self {
705        self.local_file = None;
706        self
707    }
708
709    /// Replaces the local dotenv-file path.
710    ///
711    /// This path is ignored unless the loader environment is local.
712    #[must_use]
713    pub fn dotenv_file(mut self, path: impl Into<PathBuf>) -> Self {
714        self.dotenv_file = Some(path.into());
715        self
716    }
717
718    /// Disables dotenv loading, including in the local environment.
719    #[must_use]
720    pub fn without_dotenv(mut self) -> Self {
721        self.dotenv_file = None;
722        self
723    }
724
725    /// Returns the normalized environment-variable prefix without its separator.
726    pub fn environment_prefix(&self) -> &str {
727        &self.prefix
728    }
729
730    /// Registers a product collection field for JSON-array environment input.
731    ///
732    /// Use its configuration path after prefix/separator normalization, such as
733    /// `allowed_tags` or `notifications.channels`. The standard
734    /// `http.cors_allowed_origins` field is registered automatically. Other
735    /// environment values remain literal strings, even when they contain valid
736    /// JSON array syntax.
737    #[must_use]
738    pub fn environment_collection(mut self, path: impl Into<String>) -> Self {
739        self.environment_collections
740            .insert(path.into().to_ascii_lowercase());
741        self
742    }
743
744    /// Loads, deserializes, and validates a standard configuration plus product fields.
745    pub fn load<T>(&self) -> Result<BaukitConfig<T>, LoadError>
746    where
747        T: Default + Validate + serde::de::DeserializeOwned,
748    {
749        self.load_dotenv()?;
750
751        let mut builder = Config::builder();
752        if let Some(path) = &self.local_file {
753            builder = builder.add_source(File::from(path.clone()).required(false));
754        }
755        builder = builder.add_source(CollectionEnvironment::new(
756            config::Environment::with_prefix(&self.prefix)
757                .prefix_separator("__")
758                .separator("__")
759                // Keep the source representation intact so values destined for
760                // `Secret<String>` cannot lose leading zeroes or exponent syntax.
761                // `config` still converts strings when deserializing typed fields.
762                .try_parsing(false),
763            self.environment_collections.clone(),
764        ));
765        // The bootstrap environment is authoritative because it controls whether
766        // reading a dotenv file is safe.
767        builder = builder.set_override("environment", self.environment.to_string())?;
768
769        let merged = builder.build()?;
770        let standard = merged.clone().try_deserialize::<StandardConfig>()?;
771        let product = deserialize_product_config::<T>(merged.cache)?;
772        let loaded = BaukitConfig {
773            environment: standard.environment,
774            http: standard.http,
775            ops: standard.ops,
776            database: standard.database,
777            rate_limit: standard.rate_limit,
778            telemetry: standard.telemetry,
779            shutdown: standard.shutdown,
780            product,
781        };
782        loaded.validate()?;
783        Ok(loaded)
784    }
785
786    fn load_dotenv(&self) -> Result<(), LoadError> {
787        if self.environment != Environment::Local {
788            return Ok(());
789        }
790        let Some(path) = &self.dotenv_file else {
791            return Ok(());
792        };
793        match dotenvy::from_path(path) {
794            Ok(_) => Ok(()),
795            Err(dotenvy::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
796                Ok(())
797            }
798            Err(source) => Err(LoadError::Dotenv {
799                path: path.clone(),
800                source,
801            }),
802        }
803    }
804}
805
806#[derive(Clone, Debug)]
807struct CollectionEnvironment {
808    inner: config::Environment,
809    keys: BTreeSet<String>,
810}
811
812impl CollectionEnvironment {
813    fn new(inner: config::Environment, keys: BTreeSet<String>) -> Self {
814        Self { inner, keys }
815    }
816}
817
818impl Source for CollectionEnvironment {
819    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
820        Box::new(self.clone())
821    }
822
823    fn collect(&self) -> Result<ConfigMap<String, Value>, config::ConfigError> {
824        let mut values = self.inner.collect()?;
825        for (key, value) in &mut values {
826            if self.keys.contains(key) {
827                parse_json_array(value);
828            }
829        }
830        Ok(values)
831    }
832}
833
834fn parse_json_array(value: &mut Value) {
835    let ValueKind::String(raw) = &value.kind else {
836        return;
837    };
838    let Ok(serde_json::Value::Array(items)) = serde_json::from_str(raw) else {
839        return;
840    };
841    let origin = value.origin().map(str::to_owned);
842    value.kind = ValueKind::Array(
843        items
844            .into_iter()
845            .map(|item| json_to_config_value(item, origin.as_ref()))
846            .collect(),
847    );
848}
849
850fn json_to_config_value(value: serde_json::Value, origin: Option<&String>) -> Value {
851    let kind = match value {
852        serde_json::Value::Null => ValueKind::Nil,
853        serde_json::Value::Bool(value) => ValueKind::Boolean(value),
854        serde_json::Value::Number(value) => value.as_i64().map_or_else(
855            || {
856                value.as_u64().map_or_else(
857                    || ValueKind::Float(value.as_f64().unwrap_or_default()),
858                    ValueKind::U64,
859                )
860            },
861            ValueKind::I64,
862        ),
863        serde_json::Value::String(value) => ValueKind::String(value),
864        serde_json::Value::Array(values) => ValueKind::Array(
865            values
866                .into_iter()
867                .map(|value| json_to_config_value(value, origin))
868                .collect(),
869        ),
870        serde_json::Value::Object(values) => ValueKind::Table(
871            values
872                .into_iter()
873                .map(|(key, value)| (key, json_to_config_value(value, origin)))
874                .collect(),
875        ),
876    };
877    Value::new(origin, kind)
878}
879
880fn deserialize_product_config<T>(mut merged: Value) -> Result<T, config::ConfigError>
881where
882    T: Default + serde::de::DeserializeOwned,
883{
884    if let ValueKind::Table(table) = &mut merged.kind {
885        for standard_key in [
886            "environment",
887            "http",
888            "ops",
889            "database",
890            "rate_limit",
891            "telemetry",
892            "shutdown",
893        ] {
894            table.remove(standard_key);
895        }
896        if table.is_empty() {
897            return Ok(T::default());
898        }
899    }
900    merged.try_deserialize()
901}
902
903/// An error that prevents configuration from being loaded for startup.
904#[derive(Debug, Error)]
905pub enum LoadError {
906    /// The application name cannot be represented as an environment prefix.
907    #[error("invalid application name `{app_name}` for an environment prefix: {reason}")]
908    InvalidPrefix {
909        /// Application name supplied by the caller.
910        app_name: String,
911        /// Explanation of the accepted prefix syntax.
912        reason: &'static str,
913    },
914    /// A local dotenv file exists but cannot be loaded.
915    #[error("failed to load dotenv file `{path}`: {source}")]
916    Dotenv {
917        /// Dotenv path being loaded.
918        path: PathBuf,
919        /// Underlying dotenv parser or I/O error.
920        #[source]
921        source: dotenvy::Error,
922    },
923    /// A configuration source could not be merged or deserialized.
924    #[error("failed to load configuration: {0}")]
925    Configuration(#[from] config::ConfigError),
926    /// The merged configuration violated one or more invariants.
927    #[error(transparent)]
928    Validation(#[from] ValidationErrors),
929}
930
931fn normalize_prefix(app_name: &str) -> Result<String, LoadError> {
932    if app_name.is_empty() {
933        return Err(invalid_prefix(app_name));
934    }
935    let mut prefix = String::with_capacity(app_name.len());
936    for character in app_name.chars() {
937        match character {
938            'a'..='z' => prefix.push(character.to_ascii_uppercase()),
939            'A'..='Z' | '0'..='9' | '_' => prefix.push(character),
940            '-' => prefix.push('_'),
941            _ => return Err(invalid_prefix(app_name)),
942        }
943    }
944    Ok(prefix)
945}
946
947fn invalid_prefix(app_name: &str) -> LoadError {
948    LoadError::InvalidPrefix {
949        app_name: app_name.to_owned(),
950        reason: "use only ASCII letters, digits, `_`, or `-`",
951    }
952}
953
954fn require_non_zero<T>(value: T, path: &str, errors: &mut Vec<ValidationError>)
955where
956    T: Default + PartialEq,
957{
958    if value == T::default() {
959        errors.push(ValidationError::new(path, "must be non-zero"));
960    }
961}
962
963fn validate_optional_text(value: &Option<String>, path: &str, errors: &mut Vec<ValidationError>) {
964    if value.as_ref().is_some_and(|value| value.trim().is_empty()) {
965        errors.push(ValidationError::new(path, "must not be empty when set"));
966    }
967}
968
969fn validation_result(errors: Vec<ValidationError>) -> Result<(), ValidationErrors> {
970    if errors.is_empty() {
971        Ok(())
972    } else {
973        Err(ValidationErrors::new(errors))
974    }
975}
976
977fn extend_validation(
978    target: &mut Vec<ValidationError>,
979    result: Result<(), ValidationErrors>,
980    prefix: Option<&str>,
981) {
982    if let Err(errors) = result {
983        target.extend(errors.into_errors().into_iter().map(|error| match prefix {
984            Some(prefix) => error.with_prefix(prefix),
985            None => error,
986        }));
987    }
988}
989
990mod duration_seconds {
991    use std::time::Duration;
992
993    use serde::{Deserialize, Deserializer};
994
995    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
996    where
997        D: Deserializer<'de>,
998    {
999        u64::deserialize(deserializer).map(Duration::from_secs)
1000    }
1001}
1002
1003#[cfg(test)]
1004mod tests;
1005
1006// Compiles the README's examples so they cannot drift from the API.
1007#[doc = include_str!("../README.md")]
1008#[cfg(doctest)]
1009struct ReadmeDoctests;