Skip to main content

acton_service/
config.rs

1//! Configuration management using Figment
2//!
3//! Configuration is loaded from multiple sources with the following precedence (highest to lowest):
4//! 1. Environment variables (prefix: ACTON_)
5//! 2. Current working directory: ./config.toml
6//! 3. XDG config directory: ~/.config/acton-service/{service_name}/config.toml
7//! 4. System directory: /etc/acton-service/{service_name}/config.toml
8//! 5. Default values
9
10use figment::{
11    providers::{Env, Format, Serialized, Toml},
12    Figment,
13};
14use serde::{de::DeserializeOwned, Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18use crate::error::Result;
19
20/// Main configuration structure with optional custom extensions
21///
22/// The generic parameter `T` allows users to extend the configuration with custom fields
23/// that will be automatically loaded from the same config.toml file.
24///
25/// # Examples
26///
27/// ```rust,ignore
28/// // No custom config (default)
29/// let config = Config::<()>::load()?;
30///
31/// // With custom config
32/// #[derive(Serialize, Deserialize, Clone, Default)]
33/// struct MyCustomConfig {
34///     api_key: String,
35///     feature_flags: HashMap<String, bool>,
36/// }
37///
38/// let config = Config::<MyCustomConfig>::load()?;
39/// println!("API Key: {}", config.custom.api_key);
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned"))]
43pub struct Config<T = ()>
44where
45    T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
46{
47    /// Service configuration
48    pub service: ServiceConfig,
49
50    /// Token authentication configuration (PASETO by default, JWT with feature)
51    #[serde(default)]
52    pub token: Option<TokenConfig>,
53
54    /// Rate limiting configuration
55    pub rate_limit: RateLimitConfig,
56
57    /// Middleware configuration
58    #[serde(default)]
59    pub middleware: MiddlewareConfig,
60
61    /// Database configuration (optional)
62    #[serde(default)]
63    pub database: Option<DatabaseConfig>,
64
65    /// Turso/libsql configuration (optional)
66    #[cfg(feature = "turso")]
67    #[serde(default)]
68    pub turso: Option<TursoConfig>,
69
70    /// SurrealDB configuration (optional)
71    #[cfg(feature = "surrealdb")]
72    #[serde(default)]
73    pub surrealdb: Option<SurrealDbConfig>,
74
75    /// Redis configuration (optional)
76    #[serde(default)]
77    pub redis: Option<RedisConfig>,
78
79    /// NATS configuration (optional)
80    #[serde(default)]
81    pub nats: Option<NatsConfig>,
82
83    /// ClickHouse configuration (optional)
84    #[cfg(feature = "clickhouse")]
85    #[serde(default)]
86    pub clickhouse: Option<ClickHouseConfig>,
87
88    /// OpenTelemetry configuration (optional)
89    #[serde(default)]
90    pub otlp: Option<OtlpConfig>,
91
92    /// gRPC configuration (optional)
93    #[serde(default)]
94    pub grpc: Option<GrpcConfig>,
95
96    /// WebSocket configuration (optional)
97    #[cfg(feature = "websocket")]
98    #[serde(default)]
99    pub websocket: Option<crate::websocket::WebSocketConfig>,
100
101    /// Cedar authorization configuration (optional)
102    #[cfg(feature = "cedar-authz")]
103    #[serde(default)]
104    pub cedar: Option<CedarConfig>,
105
106    /// Session configuration (optional)
107    #[cfg(feature = "session")]
108    #[serde(default)]
109    pub session: Option<crate::session::SessionConfig>,
110
111    /// Audit logging configuration (optional)
112    #[cfg(feature = "audit")]
113    #[serde(default)]
114    pub audit: Option<crate::audit::AuditConfig>,
115
116    /// Authentication configuration (optional, requires `auth` feature)
117    #[cfg(feature = "auth")]
118    #[serde(default)]
119    pub auth: Option<crate::auth::AuthConfig>,
120
121    /// Login lockout configuration (optional)
122    #[cfg(feature = "login-lockout")]
123    #[serde(default)]
124    pub lockout: Option<crate::lockout::LockoutConfig>,
125
126    /// TLS configuration (optional, requires `tls` feature)
127    #[cfg(feature = "tls")]
128    #[serde(default)]
129    pub tls: Option<TlsConfig>,
130
131    /// Journald configuration (optional, requires `journald` feature)
132    #[cfg(feature = "journald")]
133    #[serde(default)]
134    pub journald: Option<JournaldConfig>,
135
136    /// Account management configuration (optional)
137    #[cfg(feature = "accounts")]
138    #[serde(default)]
139    pub accounts: Option<crate::accounts::AccountsConfig>,
140
141    /// Background worker configuration (optional)
142    #[serde(default)]
143    pub background_worker: Option<crate::agents::BackgroundWorkerConfig>,
144
145    /// Custom configuration extensions
146    ///
147    /// Any fields in config.toml that don't match the above framework fields
148    /// will be deserialized into this field. Use `()` (unit type) for no custom config.
149    #[serde(flatten)]
150    pub custom: T,
151}
152
153/// Service-level configuration
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ServiceConfig {
156    /// Service name
157    pub name: String,
158
159    /// Port to listen on
160    #[serde(default = "default_port")]
161    pub port: u16,
162
163    /// Log level (trace, debug, info, warn, error)
164    #[serde(default = "default_log_level")]
165    pub log_level: String,
166
167    /// Request timeout in seconds
168    #[serde(default = "default_timeout")]
169    pub timeout_secs: u64,
170
171    /// Environment (dev, staging, production)
172    #[serde(default = "default_environment")]
173    pub environment: String,
174}
175
176/// Token authentication configuration
177///
178/// Supports PASETO (default) and JWT (requires `jwt` feature).
179/// Uses tagged enum for config file format discrimination.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(tag = "format", rename_all = "lowercase")]
182pub enum TokenConfig {
183    /// PASETO token configuration (default)
184    Paseto(PasetoConfig),
185    /// JWT token configuration (requires `jwt` feature)
186    #[cfg(feature = "jwt")]
187    Jwt(JwtConfig),
188}
189
190impl Default for TokenConfig {
191    fn default() -> Self {
192        TokenConfig::Paseto(PasetoConfig::default())
193    }
194}
195
196/// PASETO token configuration
197///
198/// Supports V4 Local (symmetric encryption) and V4 Public (asymmetric signatures).
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct PasetoConfig {
201    /// PASETO version (currently only "v4" supported)
202    #[serde(default = "default_paseto_version")]
203    pub version: String,
204
205    /// Token purpose: "local" (symmetric) or "public" (asymmetric)
206    #[serde(default = "default_paseto_purpose")]
207    pub purpose: String,
208
209    /// Path to key file
210    /// - For "local": 32-byte symmetric key
211    /// - For "public": Ed25519 public key (32 bytes)
212    pub key_path: PathBuf,
213
214    /// Issuer to validate (optional)
215    #[serde(default)]
216    pub issuer: Option<String>,
217
218    /// Audience to validate (optional)
219    #[serde(default)]
220    pub audience: Option<String>,
221}
222
223impl Default for PasetoConfig {
224    fn default() -> Self {
225        Self {
226            version: default_paseto_version(),
227            purpose: default_paseto_purpose(),
228            key_path: PathBuf::from("./keys/paseto.key"),
229            issuer: None,
230            audience: None,
231        }
232    }
233}
234
235fn default_paseto_version() -> String {
236    "v4".to_string()
237}
238
239fn default_paseto_purpose() -> String {
240    "local".to_string()
241}
242
243/// JWT configuration (requires `jwt` feature)
244#[cfg(feature = "jwt")]
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct JwtConfig {
247    /// Path to public key for JWT verification
248    pub public_key_path: PathBuf,
249
250    /// JWT algorithm (RS256, ES256, HS256)
251    #[serde(default = "default_jwt_algorithm")]
252    pub algorithm: String,
253
254    /// JWT issuer to validate
255    #[serde(default)]
256    pub issuer: Option<String>,
257
258    /// JWT audience to validate
259    #[serde(default)]
260    pub audience: Option<String>,
261}
262
263/// Rate limiting configuration
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct RateLimitConfig {
266    /// Requests per minute per user (global default)
267    #[serde(default = "default_per_user_rpm")]
268    pub per_user_rpm: u32,
269
270    /// Requests per minute per client (global default)
271    #[serde(default = "default_per_client_rpm")]
272    pub per_client_rpm: u32,
273
274    /// Rate limit window in seconds
275    #[serde(default = "default_window_secs")]
276    pub window_secs: u64,
277
278    /// Per-route rate limit overrides
279    ///
280    /// Routes can be specified as:
281    /// - Exact paths: `/api/v1/users`
282    /// - Method-prefixed: `POST /api/v1/uploads`
283    /// - With wildcards: `/api/v1/users/*`, `/api/*/admin`
284    /// - With ID placeholders: `/api/v1/users/{id}`
285    ///
286    /// Paths with UUIDs or numeric IDs are automatically normalized to `{id}`.
287    ///
288    /// # Example
289    /// ```toml
290    /// [rate_limit.routes."/api/v1/heavy-endpoint"]
291    /// requests_per_minute = 10
292    /// burst_size = 2
293    ///
294    /// [rate_limit.routes."POST /api/v1/uploads"]
295    /// requests_per_minute = 5
296    /// per_user = true
297    /// ```
298    #[serde(default)]
299    pub routes: std::collections::HashMap<String, RouteRateLimitConfig>,
300}
301
302/// Per-route rate limit configuration
303///
304/// Configures rate limiting for a specific route or route pattern.
305/// When a request matches a route pattern, these settings override the global defaults.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct RouteRateLimitConfig {
308    /// Maximum requests per minute for this route
309    pub requests_per_minute: u32,
310
311    /// Burst size for local (governor) rate limiting
312    ///
313    /// Allows temporary spikes above the base rate.
314    /// Only used with governor-based rate limiting.
315    #[serde(default = "default_route_burst_size")]
316    pub burst_size: u32,
317
318    /// Whether the limit is per-user (true) or global for the route (false)
319    ///
320    /// - `true`: Each user gets their own rate limit bucket for this route
321    /// - `false`: All users share a single rate limit bucket for this route
322    ///
323    /// Per-user tracking requires JWT authentication. Unauthenticated requests
324    /// fall back to IP-based tracking when `per_user` is true.
325    #[serde(default = "default_true")]
326    pub per_user: bool,
327}
328
329/// Database configuration
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct DatabaseConfig {
332    /// Database connection URL
333    pub url: String,
334
335    /// Maximum number of connections in the pool
336    #[serde(default = "default_max_connections")]
337    pub max_connections: u32,
338
339    /// Minimum idle connections
340    #[serde(default = "default_min_connections")]
341    pub min_connections: u32,
342
343    /// Connection timeout in seconds
344    #[serde(default = "default_connection_timeout")]
345    pub connection_timeout_secs: u64,
346
347    /// Maximum retry attempts for establishing database connection
348    #[serde(default = "default_max_retries")]
349    pub max_retries: u32,
350
351    /// Delay between retry attempts in seconds
352    #[serde(default = "default_retry_delay")]
353    pub retry_delay_secs: u64,
354
355    /// Whether database is optional (service can start without it)
356    #[serde(default = "default_false")]
357    pub optional: bool,
358
359    /// Whether to initialize connection lazily (in background)
360    #[serde(default = "default_lazy_init")]
361    pub lazy_init: bool,
362}
363
364/// Turso/libsql connection mode
365#[cfg(feature = "turso")]
366#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
367#[serde(rename_all = "snake_case")]
368pub enum TursoMode {
369    /// Local SQLite file (no network, like regular SQLite)
370    #[default]
371    Local,
372    /// Remote-only (connect to Turso cloud or libsql-server)
373    Remote,
374    /// Embedded replica (local SQLite that syncs with remote Turso)
375    EmbeddedReplica,
376}
377
378/// Turso/libsql database configuration
379#[cfg(feature = "turso")]
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct TursoConfig {
382    /// Connection mode
383    #[serde(default)]
384    pub mode: TursoMode,
385
386    /// Local database file path (required for Local and EmbeddedReplica modes)
387    #[serde(default)]
388    pub path: Option<PathBuf>,
389
390    /// Remote database URL (required for Remote and EmbeddedReplica modes)
391    /// Format: libsql://your-db.turso.io or http://localhost:8080
392    #[serde(default)]
393    pub url: Option<String>,
394
395    /// Authentication token (required for Remote and EmbeddedReplica modes)
396    #[serde(default)]
397    pub auth_token: Option<String>,
398
399    /// Sync interval in seconds (EmbeddedReplica mode only)
400    /// If set, enables automatic background sync
401    #[serde(default)]
402    pub sync_interval_secs: Option<u64>,
403
404    /// Encryption key for local database (optional, all modes)
405    #[serde(default)]
406    pub encryption_key: Option<String>,
407
408    /// Read-your-writes consistency (EmbeddedReplica mode only)
409    /// When true, writes are visible locally before sync completes
410    #[serde(default = "default_true")]
411    pub read_your_writes: bool,
412
413    /// Maximum retry attempts for connection
414    #[serde(default = "default_max_retries")]
415    pub max_retries: u32,
416
417    /// Delay between retry attempts in seconds
418    #[serde(default = "default_retry_delay")]
419    pub retry_delay_secs: u64,
420
421    /// Whether database is optional (service can start without it)
422    #[serde(default = "default_false")]
423    pub optional: bool,
424
425    /// Whether to initialize connection lazily (in background)
426    #[serde(default = "default_lazy_init")]
427    pub lazy_init: bool,
428}
429
430/// SurrealDB database configuration
431#[cfg(feature = "surrealdb")]
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct SurrealDbConfig {
434    /// Connection URL (ws://localhost:8000, mem://, http://localhost:8000, etc.)
435    pub url: String,
436
437    /// Namespace to use
438    #[serde(default = "default_surrealdb_namespace")]
439    pub namespace: String,
440
441    /// Database to use
442    #[serde(default = "default_surrealdb_database")]
443    pub database: String,
444
445    /// Username for authentication (optional, for root-level access)
446    #[serde(default)]
447    pub username: Option<String>,
448
449    /// Password for authentication (optional, for root-level access)
450    #[serde(default)]
451    pub password: Option<String>,
452
453    /// Maximum retry attempts for establishing connection
454    #[serde(default = "default_max_retries")]
455    pub max_retries: u32,
456
457    /// Delay between retry attempts in seconds
458    #[serde(default = "default_retry_delay")]
459    pub retry_delay_secs: u64,
460
461    /// Whether database is optional (service can start without it)
462    #[serde(default = "default_false")]
463    pub optional: bool,
464
465    /// Whether to initialize connection lazily (in background)
466    #[serde(default = "default_lazy_init")]
467    pub lazy_init: bool,
468}
469
470/// Redis configuration
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct RedisConfig {
473    /// Redis connection URL (redis://host:port or cluster URLs)
474    pub url: String,
475
476    /// Maximum number of connections in the pool
477    #[serde(default = "default_redis_max_connections")]
478    pub max_connections: usize,
479
480    /// Connection timeout in seconds
481    #[serde(default = "default_connection_timeout")]
482    pub connection_timeout_secs: u64,
483
484    /// Maximum retry attempts for establishing Redis connection
485    #[serde(default = "default_max_retries")]
486    pub max_retries: u32,
487
488    /// Delay between retry attempts in seconds
489    #[serde(default = "default_retry_delay")]
490    pub retry_delay_secs: u64,
491
492    /// Whether Redis is optional (service can start without it)
493    #[serde(default = "default_false")]
494    pub optional: bool,
495
496    /// Whether to initialize connection lazily (in background)
497    #[serde(default = "default_lazy_init")]
498    pub lazy_init: bool,
499}
500
501/// NATS configuration
502#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct NatsConfig {
504    /// NATS server URL
505    pub url: String,
506
507    /// Connection name
508    #[serde(default)]
509    pub name: Option<String>,
510
511    /// Max reconnection attempts
512    #[serde(default = "default_max_reconnects")]
513    pub max_reconnects: usize,
514
515    /// Maximum retry attempts for initial connection
516    #[serde(default = "default_max_retries")]
517    pub max_retries: u32,
518
519    /// Delay between retry attempts in seconds
520    #[serde(default = "default_retry_delay")]
521    pub retry_delay_secs: u64,
522
523    /// Whether NATS is optional (service can start without it)
524    #[serde(default = "default_false")]
525    pub optional: bool,
526
527    /// Whether to initialize connection lazily (in background)
528    #[serde(default = "default_lazy_init")]
529    pub lazy_init: bool,
530}
531
532/// ClickHouse analytical database configuration
533///
534/// ClickHouse is a columnar OLAP database used as a complementary analytical store.
535/// Unlike the primary database backends (PostgreSQL, Turso, SurrealDB), ClickHouse
536/// is composable and can be used alongside any of them.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct ClickHouseConfig {
539    /// ClickHouse HTTP URL (e.g., `http://localhost:8123`)
540    pub url: String,
541
542    /// Database name
543    #[serde(default = "default_clickhouse_database")]
544    pub database: String,
545
546    /// Username for authentication
547    #[serde(default)]
548    pub username: Option<String>,
549
550    /// Password for authentication
551    #[serde(default)]
552    pub password: Option<String>,
553
554    /// Maximum retry attempts for establishing connection
555    #[serde(default = "default_max_retries")]
556    pub max_retries: u32,
557
558    /// Delay between retry attempts in seconds
559    #[serde(default = "default_retry_delay")]
560    pub retry_delay_secs: u64,
561
562    /// Whether ClickHouse is optional (service can start without it)
563    #[serde(default = "default_false")]
564    pub optional: bool,
565
566    /// Whether to initialize connection lazily (in background)
567    #[serde(default = "default_lazy_init")]
568    pub lazy_init: bool,
569}
570
571fn default_clickhouse_database() -> String {
572    "default".to_string()
573}
574
575/// OpenTelemetry configuration
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct OtlpConfig {
578    /// OTLP endpoint URL
579    pub endpoint: String,
580
581    /// Service name for tracing
582    #[serde(default)]
583    pub service_name: Option<String>,
584
585    /// Enable tracing
586    #[serde(default = "default_true")]
587    pub enabled: bool,
588}
589
590/// gRPC server configuration
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct GrpcConfig {
593    /// Enable gRPC server
594    #[serde(default = "default_true")]
595    pub enabled: bool,
596
597    /// Use separate port for gRPC (if false, shares port with HTTP)
598    #[serde(default = "default_false")]
599    pub use_separate_port: bool,
600
601    /// gRPC port (only used if use_separate_port is true)
602    #[serde(default = "default_grpc_port")]
603    pub port: u16,
604
605    /// Enable gRPC reflection service
606    #[serde(default = "default_true")]
607    pub reflection_enabled: bool,
608
609    /// Enable gRPC health check service
610    #[serde(default = "default_true")]
611    pub health_check_enabled: bool,
612
613    /// Maximum message size in MB
614    #[serde(default = "default_grpc_max_message_mb")]
615    pub max_message_size_mb: usize,
616
617    /// Connection timeout in seconds
618    #[serde(default = "default_connection_timeout")]
619    pub connection_timeout_secs: u64,
620
621    /// Request timeout in seconds
622    #[serde(default = "default_timeout")]
623    pub timeout_secs: u64,
624
625    /// Protocol buffer runtime configuration
626    #[serde(default)]
627    pub proto: ProtoConfig,
628}
629
630/// Protocol buffer runtime configuration
631///
632/// NOTE: This is RUNTIME configuration only. Proto compilation happens at build time.
633/// See `acton_service::build_utils` for build-time proto compilation.
634#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct ProtoConfig {
636    /// Proto directory reference (for documentation/tooling only, not used during compilation)
637    ///
638    /// Build-time compilation uses `ACTON_PROTO_DIR` environment variable or `proto/` convention.
639    /// This field can be used by runtime tooling (e.g., generating OpenAPI from protos).
640    #[serde(default = "default_proto_dir")]
641    pub dir: String,
642
643    /// Service registry endpoint for dynamic service registration
644    ///
645    /// Example: "consul://localhost:8500" or "etcd://localhost:2379"
646    #[serde(default)]
647    pub service_registry: Option<String>,
648
649    /// Service mesh integration endpoint
650    ///
651    /// Used for service mesh sidecar integration (Istio, Linkerd, etc.)
652    #[serde(default)]
653    pub service_mesh_endpoint: Option<String>,
654
655    /// Enable proto validation (if using buf validate or similar)
656    #[serde(default = "default_false")]
657    pub validation_enabled: bool,
658
659    /// Service metadata for discovery and registration
660    ///
661    /// Key-value pairs for service mesh/registry metadata
662    #[serde(default)]
663    pub metadata: std::collections::HashMap<String, String>,
664}
665
666impl Default for ProtoConfig {
667    fn default() -> Self {
668        Self {
669            dir: default_proto_dir(),
670            service_registry: None,
671            service_mesh_endpoint: None,
672            validation_enabled: false,
673            metadata: std::collections::HashMap::new(),
674        }
675    }
676}
677
678impl GrpcConfig {
679    /// Get the effective port (either separate port or HTTP port)
680    pub fn effective_port(&self, http_port: u16) -> u16 {
681        if self.use_separate_port {
682            self.port
683        } else {
684            http_port
685        }
686    }
687
688    /// Get max message size in bytes
689    pub fn max_message_size_bytes(&self) -> usize {
690        self.max_message_size_mb * 1024 * 1024
691    }
692
693    /// Get connection timeout as Duration
694    pub fn connection_timeout(&self) -> Duration {
695        Duration::from_secs(self.connection_timeout_secs)
696    }
697
698    /// Get request timeout as Duration
699    pub fn timeout(&self) -> Duration {
700        Duration::from_secs(self.timeout_secs)
701    }
702}
703
704/// Cedar authorization configuration
705#[cfg(feature = "cedar-authz")]
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct CedarConfig {
708    /// Enable Cedar authorization
709    #[serde(default = "default_false")]
710    pub enabled: bool,
711
712    /// Path to Cedar policy file
713    pub policy_path: PathBuf,
714
715    /// Enable policy hot-reload (watch file for changes)
716    #[serde(default = "default_false")]
717    pub hot_reload: bool,
718
719    /// Hot-reload check interval in seconds
720    #[serde(default = "default_cedar_hot_reload_interval")]
721    pub hot_reload_interval_secs: u64,
722
723    /// Enable policy caching (requires cache feature)
724    #[serde(default = "default_true")]
725    pub cache_enabled: bool,
726
727    /// Policy cache TTL in seconds
728    #[serde(default = "default_cedar_policy_cache_ttl")]
729    pub cache_ttl_secs: u64,
730
731    /// Fail open on policy evaluation errors
732    /// - true: Allow requests when policy evaluation fails (permissive)
733    /// - false: Deny requests when policy evaluation fails (strict)
734    #[serde(default = "default_false")]
735    pub fail_open: bool,
736}
737
738#[cfg(feature = "cedar-authz")]
739impl CedarConfig {
740    /// Get hot-reload interval as Duration
741    pub fn hot_reload_interval(&self) -> Duration {
742        Duration::from_secs(self.hot_reload_interval_secs)
743    }
744
745    /// Get cache TTL as Duration
746    pub fn cache_ttl(&self) -> Duration {
747        Duration::from_secs(self.cache_ttl_secs)
748    }
749}
750
751/// TLS configuration (requires `tls` feature)
752///
753/// When enabled, the server listens for HTTPS connections using rustls.
754/// Certificates are loaded at startup from PEM files.
755#[cfg(feature = "tls")]
756#[derive(Debug, Clone, Serialize, Deserialize)]
757pub struct TlsConfig {
758    /// Enable TLS (default: true when section is present)
759    #[serde(default = "default_true")]
760    pub enabled: bool,
761
762    /// Path to PEM-encoded certificate chain
763    pub cert_path: PathBuf,
764
765    /// Path to PEM-encoded private key
766    pub key_path: PathBuf,
767}
768
769/// Journald logging configuration (requires `journald` feature)
770///
771/// When enabled, tracing events are written directly to the systemd journal
772/// with native structured fields instead of embedding JSON strings.
773#[cfg(feature = "journald")]
774#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct JournaldConfig {
776    /// Enable journald output (default: true when section is present)
777    #[serde(default = "default_true")]
778    pub enabled: bool,
779
780    /// Syslog identifier for `journalctl -t <identifier>`
781    /// Defaults to the service name
782    #[serde(default)]
783    pub syslog_identifier: Option<String>,
784
785    /// Field prefix for user-defined fields (default: "F" per tracing-journald)
786    /// Set to empty string to disable prefixing
787    #[serde(default)]
788    pub field_prefix: Option<String>,
789
790    /// Disable the JSON fmt layer when journald is active
791    /// Prevents double output on systemd systems where stdout goes to journal
792    #[serde(default = "default_false")]
793    pub disable_fmt_layer: bool,
794}
795
796/// Security headers configuration
797///
798/// Controls HTTP security headers (HSTS, X-Content-Type-Options, etc.).
799/// No feature gate required -- uses existing `tower-http` `set-header`.
800#[derive(Debug, Clone, Serialize, Deserialize)]
801pub struct SecurityHeadersConfig {
802    /// Enable security headers middleware (default: true)
803    #[serde(default = "default_true")]
804    pub enabled: bool,
805
806    /// Send Strict-Transport-Security header (only when TLS is active)
807    #[serde(default = "default_true")]
808    pub hsts: bool,
809
810    /// HSTS max-age in seconds (default: 63072000 = 2 years, OWASP recommendation)
811    #[serde(default = "default_hsts_max_age")]
812    pub hsts_max_age_secs: u64,
813
814    /// Include subdomains in HSTS
815    #[serde(default = "default_false")]
816    pub hsts_include_subdomains: bool,
817
818    /// Add HSTS preload flag
819    #[serde(default = "default_false")]
820    pub hsts_preload: bool,
821
822    /// Send X-Content-Type-Options: nosniff
823    #[serde(default = "default_true")]
824    pub x_content_type_options: bool,
825
826    /// X-Frame-Options value (default: "DENY")
827    #[serde(default = "default_x_frame_options")]
828    pub x_frame_options: String,
829
830    /// Send X-XSS-Protection: 0 (modern recommendation: disable browser XSS filter)
831    #[serde(default = "default_true")]
832    pub x_xss_protection: bool,
833
834    /// Referrer-Policy value (default: "strict-origin-when-cross-origin")
835    #[serde(default = "default_referrer_policy")]
836    pub referrer_policy: String,
837
838    /// Permissions-Policy header value (optional, user-configured)
839    #[serde(default)]
840    pub permissions_policy: Option<String>,
841}
842
843impl Default for SecurityHeadersConfig {
844    fn default() -> Self {
845        Self {
846            enabled: true,
847            hsts: true,
848            hsts_max_age_secs: default_hsts_max_age(),
849            hsts_include_subdomains: false,
850            hsts_preload: false,
851            x_content_type_options: true,
852            x_frame_options: default_x_frame_options(),
853            x_xss_protection: true,
854            referrer_policy: default_referrer_policy(),
855            permissions_policy: None,
856        }
857    }
858}
859
860/// Middleware configuration (all optional, feature-gated)
861#[derive(Debug, Clone, Serialize, Deserialize)]
862pub struct MiddlewareConfig {
863    /// Request tracking configuration (request IDs, header propagation)
864    #[serde(default)]
865    pub request_tracking: RequestTrackingConfig,
866
867    /// Resilience configuration (circuit breaker, retry, bulkhead)
868    #[serde(default)]
869    pub resilience: Option<ResilienceConfig>,
870
871    /// HTTP metrics configuration (OpenTelemetry)
872    #[serde(default)]
873    pub metrics: Option<MetricsConfig>,
874
875    /// Local rate limiting configuration (governor)
876    #[serde(default)]
877    pub governor: Option<LocalRateLimitConfig>,
878
879    /// Request body size limit in MB
880    #[serde(default = "default_body_limit_mb")]
881    pub body_limit_mb: usize,
882
883    /// Enable panic recovery middleware
884    #[serde(default = "default_true")]
885    pub catch_panic: bool,
886
887    /// Enable compression
888    #[serde(default = "default_true")]
889    pub compression: bool,
890
891    /// CORS configuration
892    #[serde(default = "default_cors_mode")]
893    pub cors_mode: String,
894
895    /// Security headers configuration (HSTS, X-Content-Type-Options, etc.)
896    #[serde(default)]
897    pub security_headers: SecurityHeadersConfig,
898}
899
900impl Default for MiddlewareConfig {
901    fn default() -> Self {
902        Self {
903            request_tracking: RequestTrackingConfig::default(),
904            resilience: None,
905            metrics: None,
906            governor: None,
907            body_limit_mb: default_body_limit_mb(),
908            catch_panic: true,
909            compression: true,
910            cors_mode: default_cors_mode(),
911            security_headers: SecurityHeadersConfig::default(),
912        }
913    }
914}
915
916/// Request tracking configuration
917#[derive(Debug, Clone, Serialize, Deserialize)]
918pub struct RequestTrackingConfig {
919    /// Enable request ID generation
920    #[serde(default = "default_true")]
921    pub request_id_enabled: bool,
922
923    /// Request ID header name
924    #[serde(default = "default_request_id_header")]
925    pub request_id_header: String,
926
927    /// Enable header propagation
928    #[serde(default = "default_true")]
929    pub propagate_headers: bool,
930
931    /// Enable sensitive header masking in logs
932    #[serde(default = "default_true")]
933    pub mask_sensitive_headers: bool,
934}
935
936impl Default for RequestTrackingConfig {
937    fn default() -> Self {
938        Self {
939            request_id_enabled: true,
940            request_id_header: default_request_id_header(),
941            propagate_headers: true,
942            mask_sensitive_headers: true,
943        }
944    }
945}
946
947/// Resilience configuration (circuit breaker, retry, bulkhead)
948#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct ResilienceConfig {
950    /// Enable circuit breaker
951    #[serde(default = "default_true")]
952    pub circuit_breaker_enabled: bool,
953
954    /// Failure threshold before circuit opens (0.0-1.0)
955    #[serde(default = "default_circuit_breaker_threshold")]
956    pub circuit_breaker_threshold: f64,
957
958    /// Minimum requests before calculating failure rate
959    #[serde(default = "default_circuit_breaker_min_requests")]
960    pub circuit_breaker_min_requests: u64,
961
962    /// Duration to wait before attempting to close circuit (seconds)
963    #[serde(default = "default_circuit_breaker_wait_secs")]
964    pub circuit_breaker_wait_secs: u64,
965
966    /// Enable retry logic
967    #[serde(default = "default_true")]
968    pub retry_enabled: bool,
969
970    /// Maximum number of retry attempts
971    #[serde(default = "default_retry_max_attempts")]
972    pub retry_max_attempts: usize,
973
974    /// Base delay for exponential backoff (milliseconds)
975    #[serde(default = "default_retry_base_delay_ms")]
976    pub retry_base_delay_ms: u64,
977
978    /// Maximum delay for exponential backoff (milliseconds)
979    #[serde(default = "default_retry_max_delay_ms")]
980    pub retry_max_delay_ms: u64,
981
982    /// Enable bulkhead (concurrency limiting)
983    #[serde(default = "default_true")]
984    pub bulkhead_enabled: bool,
985
986    /// Maximum concurrent requests
987    #[serde(default = "default_bulkhead_max_concurrent")]
988    pub bulkhead_max_concurrent: usize,
989
990    /// Maximum queued requests
991    #[serde(default = "default_bulkhead_max_queued")]
992    pub bulkhead_max_queued: usize,
993}
994
995impl ResilienceConfig {
996    /// Convert to Duration types for runtime use
997    pub fn circuit_breaker_wait_duration(&self) -> Duration {
998        Duration::from_secs(self.circuit_breaker_wait_secs)
999    }
1000
1001    pub fn retry_base_delay(&self) -> Duration {
1002        Duration::from_millis(self.retry_base_delay_ms)
1003    }
1004
1005    pub fn retry_max_delay(&self) -> Duration {
1006        Duration::from_millis(self.retry_max_delay_ms)
1007    }
1008}
1009
1010/// HTTP metrics configuration (OpenTelemetry)
1011#[derive(Debug, Clone, Serialize, Deserialize)]
1012pub struct MetricsConfig {
1013    /// Enable metrics collection
1014    #[serde(default = "default_true")]
1015    pub enabled: bool,
1016
1017    /// Include request path in metrics
1018    #[serde(default = "default_true")]
1019    pub include_path: bool,
1020
1021    /// Include request method in metrics
1022    #[serde(default = "default_true")]
1023    pub include_method: bool,
1024
1025    /// Include status code in metrics
1026    #[serde(default = "default_true")]
1027    pub include_status: bool,
1028
1029    /// Histogram buckets for latency (in milliseconds)
1030    #[serde(default = "default_latency_buckets")]
1031    pub latency_buckets_ms: Vec<f64>,
1032}
1033
1034impl MetricsConfig {
1035    pub fn latency_buckets_as_duration(&self) -> Vec<Duration> {
1036        self.latency_buckets_ms
1037            .iter()
1038            .map(|&ms| Duration::from_millis(ms as u64))
1039            .collect()
1040    }
1041}
1042
1043/// Local rate limiting configuration (governor-based)
1044#[derive(Debug, Clone, Serialize, Deserialize)]
1045pub struct LocalRateLimitConfig {
1046    /// Enable local rate limiting
1047    #[serde(default = "default_true")]
1048    pub enabled: bool,
1049
1050    /// Maximum requests per period
1051    #[serde(default = "default_governor_requests")]
1052    pub requests_per_period: u32,
1053
1054    /// Time period in seconds
1055    #[serde(default = "default_governor_period_secs")]
1056    pub period_secs: u64,
1057
1058    /// Burst size (allow temporary spikes)
1059    #[serde(default = "default_governor_burst")]
1060    pub burst_size: u32,
1061}
1062
1063impl LocalRateLimitConfig {
1064    pub fn period(&self) -> Duration {
1065        Duration::from_secs(self.period_secs)
1066    }
1067}
1068
1069// Default value functions
1070fn default_port() -> u16 {
1071    8080
1072}
1073
1074fn default_log_level() -> String {
1075    "info".to_string()
1076}
1077
1078fn default_timeout() -> u64 {
1079    30
1080}
1081
1082fn default_environment() -> String {
1083    "dev".to_string()
1084}
1085
1086#[cfg(feature = "jwt")]
1087fn default_jwt_algorithm() -> String {
1088    "RS256".to_string()
1089}
1090
1091fn default_per_user_rpm() -> u32 {
1092    200
1093}
1094
1095fn default_per_client_rpm() -> u32 {
1096    1000
1097}
1098
1099fn default_window_secs() -> u64 {
1100    60
1101}
1102
1103fn default_route_burst_size() -> u32 {
1104    10 // 10% burst allowance by default
1105}
1106
1107fn default_max_connections() -> u32 {
1108    50
1109}
1110
1111fn default_min_connections() -> u32 {
1112    5
1113}
1114
1115fn default_connection_timeout() -> u64 {
1116    10
1117}
1118
1119fn default_redis_max_connections() -> usize {
1120    20
1121}
1122
1123fn default_max_reconnects() -> usize {
1124    10
1125}
1126
1127fn default_true() -> bool {
1128    true
1129}
1130
1131fn default_false() -> bool {
1132    false
1133}
1134
1135fn default_max_retries() -> u32 {
1136    5
1137}
1138
1139fn default_retry_delay() -> u64 {
1140    2
1141}
1142
1143fn default_lazy_init() -> bool {
1144    true
1145}
1146
1147#[cfg(feature = "surrealdb")]
1148fn default_surrealdb_namespace() -> String {
1149    "default".to_string()
1150}
1151
1152#[cfg(feature = "surrealdb")]
1153fn default_surrealdb_database() -> String {
1154    "default".to_string()
1155}
1156
1157// Security headers default functions
1158fn default_hsts_max_age() -> u64 {
1159    63_072_000 // 2 years (OWASP recommendation)
1160}
1161
1162fn default_x_frame_options() -> String {
1163    "DENY".to_string()
1164}
1165
1166fn default_referrer_policy() -> String {
1167    "strict-origin-when-cross-origin".to_string()
1168}
1169
1170// Middleware default functions
1171fn default_body_limit_mb() -> usize {
1172    10 // 10 MB
1173}
1174
1175fn default_cors_mode() -> String {
1176    "restrictive".to_string()
1177}
1178
1179fn default_request_id_header() -> String {
1180    "x-request-id".to_string()
1181}
1182
1183// Resilience default functions
1184fn default_circuit_breaker_threshold() -> f64 {
1185    0.5 // 50% failure rate
1186}
1187
1188fn default_circuit_breaker_min_requests() -> u64 {
1189    10
1190}
1191
1192fn default_circuit_breaker_wait_secs() -> u64 {
1193    30
1194}
1195
1196fn default_retry_max_attempts() -> usize {
1197    3
1198}
1199
1200fn default_retry_base_delay_ms() -> u64 {
1201    100
1202}
1203
1204fn default_retry_max_delay_ms() -> u64 {
1205    10000 // 10 seconds
1206}
1207
1208fn default_bulkhead_max_concurrent() -> usize {
1209    100
1210}
1211
1212fn default_bulkhead_max_queued() -> usize {
1213    200
1214}
1215
1216// Metrics default functions
1217fn default_latency_buckets() -> Vec<f64> {
1218    vec![
1219        5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0,
1220    ]
1221}
1222
1223// Governor default functions
1224fn default_governor_requests() -> u32 {
1225    100
1226}
1227
1228fn default_governor_period_secs() -> u64 {
1229    60
1230}
1231
1232fn default_governor_burst() -> u32 {
1233    10
1234}
1235
1236// gRPC default functions
1237fn default_grpc_port() -> u16 {
1238    9090
1239}
1240
1241fn default_grpc_max_message_mb() -> usize {
1242    4 // 4 MB
1243}
1244
1245fn default_proto_dir() -> String {
1246    "proto".to_string()
1247}
1248
1249// Cedar default functions
1250#[cfg(feature = "cedar-authz")]
1251fn default_cedar_hot_reload_interval() -> u64 {
1252    60 // Check every 60 seconds
1253}
1254
1255#[cfg(feature = "cedar-authz")]
1256fn default_cedar_policy_cache_ttl() -> u64 {
1257    300 // Cache for 5 minutes
1258}
1259
1260impl<T> Config<T>
1261where
1262    T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
1263{
1264    /// Load configuration from all sources
1265    ///
1266    /// Searches for config files in this order (first found is used):
1267    /// 1. Current working directory: ./config.toml
1268    /// 2. XDG config directory: ~/.config/acton-service/{service_name}/config.toml
1269    /// 3. System directory: /etc/acton-service/{service_name}/config.toml
1270    ///
1271    /// Environment variables (ACTON_ prefix) override all file-based configs.
1272    ///
1273    /// Both framework config and custom config (type T) are loaded from the same config.toml.
1274    pub fn load() -> Result<Self> {
1275        // Try to infer service name from binary name or use default
1276        let service_name = std::env::current_exe()
1277            .ok()
1278            .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
1279            .unwrap_or_else(|| "acton-service".to_string());
1280
1281        Self::load_for_service(&service_name)
1282    }
1283
1284    /// Load configuration for a specific service name
1285    ///
1286    /// This is the recommended way to load config in production.
1287    pub fn load_for_service(service_name: &str) -> Result<Self> {
1288        let config_paths = Self::find_config_paths(service_name);
1289
1290        // Log which config paths we're checking
1291        tracing::debug!("Searching for config files in order:");
1292        for path in &config_paths {
1293            tracing::debug!("  - {}", path.display());
1294        }
1295
1296        let mut figment = Figment::new()
1297            // Start with defaults
1298            .merge(Serialized::defaults(Config::<T>::default()));
1299
1300        // Merge config files in reverse order (lowest priority first)
1301        // so that higher priority files override lower ones
1302        for path in config_paths.iter().rev() {
1303            if path.exists() {
1304                tracing::info!("Loading configuration from: {}", path.display());
1305                figment = figment.merge(Toml::file(path));
1306            }
1307        }
1308
1309        // Environment variables have highest priority
1310        figment = figment.merge(Env::prefixed("ACTON_").split("_"));
1311
1312        let config = figment.extract()?;
1313        Ok(config)
1314    }
1315
1316    /// Load configuration from a specific file
1317    ///
1318    /// This bypasses XDG directories and loads directly from the given path.
1319    /// Useful for testing or non-standard deployments.
1320    pub fn load_from(path: &str) -> Result<Self> {
1321        let config = Figment::new()
1322            // Start with defaults
1323            .merge(Serialized::defaults(Config::<T>::default()))
1324            // Load from config file (if exists)
1325            .merge(Toml::file(path))
1326            // Override with environment variables
1327            .merge(Env::prefixed("ACTON_").split("_"))
1328            .extract()?;
1329
1330        Ok(config)
1331    }
1332
1333    /// Find all possible config file paths for a service
1334    ///
1335    /// Returns paths in priority order (highest first):
1336    /// 1. Current working directory
1337    /// 2. XDG config directory
1338    /// 3. System directory
1339    fn find_config_paths(service_name: &str) -> Vec<PathBuf> {
1340        let mut paths = Vec::new();
1341
1342        // 1. Current working directory (highest priority for dev/testing)
1343        paths.push(PathBuf::from("config.toml"));
1344
1345        // 2. XDG config directory (~/.config/acton-service/{service_name}/config.toml)
1346        // Use find_config_file instead of place_config_file to avoid creating directories
1347        let xdg_dirs = xdg::BaseDirectories::with_prefix("acton-service");
1348        let config_file_path = Path::new(service_name).join("config.toml");
1349        if let Some(path) = xdg_dirs.find_config_file(&config_file_path) {
1350            paths.push(path);
1351        }
1352
1353        // 3. System-wide directory (/etc/acton-service/{service_name}/config.toml)
1354        paths.push(
1355            PathBuf::from("/etc/acton-service")
1356                .join(service_name)
1357                .join("config.toml"),
1358        );
1359
1360        paths
1361    }
1362
1363    /// Get the recommended config path for a service
1364    ///
1365    /// This is where the config file should be placed in production.
1366    /// Returns: ~/.config/acton-service/{service_name}/config.toml
1367    pub fn recommended_path(service_name: &str) -> PathBuf {
1368        let xdg_dirs = xdg::BaseDirectories::with_prefix("acton-service");
1369        let config_file_path = Path::new(service_name).join("config.toml");
1370
1371        // place_config_file creates parent directories if needed
1372        xdg_dirs
1373            .place_config_file(&config_file_path)
1374            .unwrap_or_else(|_| {
1375                // Fallback to manual path construction if place_config_file fails
1376                PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| String::from("~")))
1377                    .join(".config/acton-service")
1378                    .join(service_name)
1379                    .join("config.toml")
1380            })
1381    }
1382
1383    /// Create the config directory structure for a service
1384    ///
1385    /// Creates ~/.config/acton-service/{service_name}/ if it doesn't exist
1386    pub fn create_config_dir(service_name: &str) -> Result<PathBuf> {
1387        let xdg_dirs = xdg::BaseDirectories::with_prefix("acton-service");
1388        let config_file_path = Path::new(service_name).join("config.toml");
1389
1390        // place_config_file creates all necessary parent directories
1391        let config_path = xdg_dirs.place_config_file(&config_file_path).map_err(|e| {
1392            crate::error::Error::Internal(format!("Failed to create config directory: {}", e))
1393        })?;
1394
1395        // Return the directory path, not the file path
1396        Ok(config_path
1397            .parent()
1398            .ok_or_else(|| crate::error::Error::Internal("Invalid config path".to_string()))?
1399            .to_path_buf())
1400    }
1401
1402    /// Get database URL
1403    pub fn database_url(&self) -> Option<&str> {
1404        self.database.as_ref().map(|db| db.url.as_str())
1405    }
1406
1407    /// Get Redis URL
1408    pub fn redis_url(&self) -> Option<&str> {
1409        self.redis.as_ref().map(|r| r.url.as_str())
1410    }
1411
1412    /// Get NATS URL
1413    pub fn nats_url(&self) -> Option<&str> {
1414        self.nats.as_ref().map(|n| n.url.as_str())
1415    }
1416
1417    /// Get Turso remote URL
1418    #[cfg(feature = "turso")]
1419    pub fn turso_url(&self) -> Option<&str> {
1420        self.turso.as_ref().and_then(|t| t.url.as_deref())
1421    }
1422
1423    /// Get SurrealDB URL
1424    #[cfg(feature = "surrealdb")]
1425    pub fn surrealdb_url(&self) -> Option<&str> {
1426        self.surrealdb.as_ref().map(|s| s.url.as_str())
1427    }
1428
1429    /// Enable permissive CORS for local development
1430    ///
1431    /// ⚠️  **WARNING: DO NOT USE IN PRODUCTION** ⚠️
1432    ///
1433    /// This enables permissive CORS that allows:
1434    /// - All origins (*)
1435    /// - All methods (GET, POST, PUT, DELETE, etc.)
1436    /// - All headers
1437    /// - Credentials from any origin
1438    ///
1439    /// This configuration is appropriate ONLY for:
1440    /// - Local development environments
1441    /// - Testing with frontend dev servers (e.g., webpack-dev-server, vite)
1442    /// - Prototyping where security is not a concern
1443    ///
1444    /// For production, you should:
1445    /// - Use the default restrictive CORS (secure by default)
1446    /// - Configure specific allowed origins in your config file
1447    /// - Set ACTON_MIDDLEWARE_CORS_MODE=restrictive
1448    ///
1449    /// # Example
1450    /// ```no_run
1451    /// use acton_service::prelude::Config;
1452    ///
1453    /// let mut config = Config::<()>::load().unwrap();
1454    /// config.with_development_cors(); // Only for local development!
1455    /// ```
1456    pub fn with_development_cors(&mut self) -> &mut Self {
1457        tracing::warn!(
1458            "⚠️  CORS set to permissive mode - DO NOT USE IN PRODUCTION! \
1459             This allows any origin to access your API. \
1460             Use only for local development."
1461        );
1462        self.middleware.cors_mode = "permissive".to_string();
1463        self
1464    }
1465}
1466
1467impl<T> Default for Config<T>
1468where
1469    T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
1470{
1471    fn default() -> Self {
1472        Self {
1473            service: ServiceConfig {
1474                name: "acton-service".to_string(),
1475                port: default_port(),
1476                log_level: default_log_level(),
1477                timeout_secs: default_timeout(),
1478                environment: default_environment(),
1479            },
1480            token: None,
1481            rate_limit: RateLimitConfig {
1482                per_user_rpm: default_per_user_rpm(),
1483                per_client_rpm: default_per_client_rpm(),
1484                window_secs: default_window_secs(),
1485                routes: std::collections::HashMap::new(),
1486            },
1487            middleware: MiddlewareConfig::default(),
1488            database: None,
1489            #[cfg(feature = "turso")]
1490            turso: None,
1491            #[cfg(feature = "surrealdb")]
1492            surrealdb: None,
1493            redis: None,
1494            nats: None,
1495            #[cfg(feature = "clickhouse")]
1496            clickhouse: None,
1497            otlp: None,
1498            grpc: None,
1499            #[cfg(feature = "websocket")]
1500            websocket: None,
1501            #[cfg(feature = "cedar-authz")]
1502            cedar: None,
1503            #[cfg(feature = "session")]
1504            session: None,
1505            #[cfg(feature = "audit")]
1506            audit: None,
1507            #[cfg(feature = "auth")]
1508            auth: None,
1509            #[cfg(feature = "login-lockout")]
1510            lockout: None,
1511            #[cfg(feature = "tls")]
1512            tls: None,
1513            #[cfg(feature = "journald")]
1514            journald: None,
1515            #[cfg(feature = "accounts")]
1516            accounts: None,
1517            background_worker: None,
1518            custom: T::default(),
1519        }
1520    }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525    use super::*;
1526    use std::collections::HashMap;
1527
1528    #[test]
1529    fn test_default_config() {
1530        let config = Config::<()>::default();
1531        assert_eq!(config.service.port, 8080);
1532        assert_eq!(config.service.log_level, "info");
1533        assert_eq!(config.rate_limit.per_user_rpm, 200);
1534    }
1535
1536    #[test]
1537    fn test_default_config_with_unit_type() {
1538        let config = Config::<()>::default();
1539        assert_eq!(config.service.port, 8080);
1540        assert_eq!(config.service.name, "acton-service");
1541        // config.custom is () - no assertion needed for unit type
1542    }
1543
1544    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1545    struct CustomConfig {
1546        api_key: String,
1547        timeout_ms: u32,
1548        feature_flags: HashMap<String, bool>,
1549    }
1550
1551    #[test]
1552    fn test_config_with_custom_type() {
1553        let custom = CustomConfig {
1554            api_key: "test-key-123".to_string(),
1555            timeout_ms: 5000,
1556            feature_flags: {
1557                let mut map = HashMap::new();
1558                map.insert("new_ui".to_string(), true);
1559                map.insert("beta_features".to_string(), false);
1560                map
1561            },
1562        };
1563
1564        let config = Config {
1565            service: ServiceConfig {
1566                name: "test-service".to_string(),
1567                port: 9090,
1568                log_level: "debug".to_string(),
1569                timeout_secs: 30,
1570                environment: "test".to_string(),
1571            },
1572            token: Some(TokenConfig::Paseto(PasetoConfig {
1573                version: "v4".to_string(),
1574                purpose: "local".to_string(),
1575                key_path: PathBuf::from("./test-key.key"),
1576                issuer: Some("test-issuer".to_string()),
1577                audience: None,
1578            })),
1579            rate_limit: RateLimitConfig {
1580                per_user_rpm: 100,
1581                per_client_rpm: 500,
1582                window_secs: 60,
1583                routes: std::collections::HashMap::new(),
1584            },
1585            middleware: MiddlewareConfig::default(),
1586            database: None,
1587            #[cfg(feature = "turso")]
1588            turso: None,
1589            #[cfg(feature = "surrealdb")]
1590            surrealdb: None,
1591            redis: None,
1592            nats: None,
1593            #[cfg(feature = "clickhouse")]
1594            clickhouse: None,
1595            otlp: None,
1596            grpc: None,
1597            #[cfg(feature = "websocket")]
1598            websocket: None,
1599            #[cfg(feature = "cedar-authz")]
1600            cedar: None,
1601            #[cfg(feature = "session")]
1602            session: None,
1603            #[cfg(feature = "audit")]
1604            audit: None,
1605            #[cfg(feature = "auth")]
1606            auth: None,
1607            #[cfg(feature = "login-lockout")]
1608            lockout: None,
1609            #[cfg(feature = "tls")]
1610            tls: None,
1611            #[cfg(feature = "journald")]
1612            journald: None,
1613            #[cfg(feature = "accounts")]
1614            accounts: None,
1615            background_worker: None,
1616            custom,
1617        };
1618
1619        assert_eq!(config.service.name, "test-service");
1620        assert_eq!(config.custom.api_key, "test-key-123");
1621        assert_eq!(config.custom.timeout_ms, 5000);
1622        assert_eq!(config.custom.feature_flags.get("new_ui"), Some(&true));
1623    }
1624
1625    #[test]
1626    fn test_config_serialization_with_custom() {
1627        let custom = CustomConfig {
1628            api_key: "secret-key".to_string(),
1629            timeout_ms: 3000,
1630            feature_flags: HashMap::new(),
1631        };
1632
1633        let config = Config {
1634            service: ServiceConfig {
1635                name: "test".to_string(),
1636                port: 8080,
1637                log_level: "info".to_string(),
1638                timeout_secs: 30,
1639                environment: "dev".to_string(),
1640            },
1641            token: None,
1642            rate_limit: RateLimitConfig {
1643                per_user_rpm: 200,
1644                per_client_rpm: 1000,
1645                window_secs: 60,
1646                routes: std::collections::HashMap::new(),
1647            },
1648            middleware: MiddlewareConfig::default(),
1649            database: None,
1650            #[cfg(feature = "turso")]
1651            turso: None,
1652            #[cfg(feature = "surrealdb")]
1653            surrealdb: None,
1654            redis: None,
1655            nats: None,
1656            #[cfg(feature = "clickhouse")]
1657            clickhouse: None,
1658            otlp: None,
1659            grpc: None,
1660            #[cfg(feature = "websocket")]
1661            websocket: None,
1662            #[cfg(feature = "cedar-authz")]
1663            cedar: None,
1664            #[cfg(feature = "session")]
1665            session: None,
1666            #[cfg(feature = "audit")]
1667            audit: None,
1668            #[cfg(feature = "auth")]
1669            auth: None,
1670            #[cfg(feature = "login-lockout")]
1671            lockout: None,
1672            #[cfg(feature = "tls")]
1673            tls: None,
1674            #[cfg(feature = "journald")]
1675            journald: None,
1676            #[cfg(feature = "accounts")]
1677            accounts: None,
1678            background_worker: None,
1679            custom: custom.clone(),
1680        };
1681
1682        // Serialize to JSON
1683        let json = serde_json::to_string(&config).expect("Failed to serialize");
1684
1685        // Deserialize back
1686        let deserialized: Config<CustomConfig> =
1687            serde_json::from_str(&json).expect("Failed to deserialize");
1688
1689        assert_eq!(deserialized.custom, custom);
1690        assert_eq!(deserialized.service.name, "test");
1691    }
1692
1693    #[test]
1694    fn test_config_deserialization_with_flatten() {
1695        // Simulate a JSON config with both framework and custom fields
1696        let json_str = r#"{
1697            "service": {
1698                "name": "my-service",
1699                "port": 9000,
1700                "log_level": "debug",
1701                "timeout_secs": 60,
1702                "environment": "production"
1703            },
1704            "token": {
1705                "format": "paseto",
1706                "version": "v4",
1707                "purpose": "local",
1708                "key_path": "./keys/paseto.key"
1709            },
1710            "rate_limit": {
1711                "per_user_rpm": 150,
1712                "per_client_rpm": 750,
1713                "window_secs": 60
1714            },
1715            "middleware": {
1716                "cors_mode": "restrictive",
1717                "body_limit_mb": 10,
1718                "compression_enabled": true
1719            },
1720            "api_key": "prod-api-key",
1721            "timeout_ms": 10000,
1722            "feature_flags": {
1723                "new_dashboard": true,
1724                "analytics": true
1725            }
1726        }"#;
1727
1728        let config: Config<CustomConfig> =
1729            serde_json::from_str(json_str).expect("Failed to parse JSON");
1730
1731        // Verify framework config
1732        assert_eq!(config.service.name, "my-service");
1733        assert_eq!(config.service.port, 9000);
1734        assert_eq!(config.service.log_level, "debug");
1735
1736        // Verify custom config (flattened fields)
1737        assert_eq!(config.custom.api_key, "prod-api-key");
1738        assert_eq!(config.custom.timeout_ms, 10000);
1739        assert_eq!(
1740            config.custom.feature_flags.get("new_dashboard"),
1741            Some(&true)
1742        );
1743        assert_eq!(config.custom.feature_flags.get("analytics"), Some(&true));
1744    }
1745}