acton_htmx/config/
mod.rs

1//! Configuration management for acton-htmx
2//!
3//! Extends acton-service's XDG-compliant configuration system with HTMX-specific
4//! settings. Configuration is loaded from multiple sources with clear precedence:
5//!
6//! 1. Environment variables (highest priority, `ACTON_` prefix, `__` for nesting)
7//! 2. `./config.toml` (development)
8//! 3. `~/.config/acton-htmx/config.toml` (user config, XDG)
9//! 4. `/etc/acton-htmx/config.toml` (system config)
10//! 5. Hardcoded defaults (fallback)
11//!
12//! Environment variable format: `ACTON_SECTION__FIELD_NAME`
13//! - Use `__` (double underscore) to separate nested sections
14//! - Use `_` (single underscore) within field names
15//! - Example: `ACTON_HTMX__REQUEST_TIMEOUT_MS=5000`
16//!
17//! # Example Configuration
18//!
19//! ```toml
20//! # config.toml
21//! [service]
22//! name = "my-htmx-app"
23//! port = 3000
24//!
25//! [database]
26//! url = "sqlite://./dev.db"
27//! optional = true
28//! lazy_init = true
29//!
30//! [htmx]
31//! request_timeout_ms = 5000
32//! history_enabled = true
33//! auto_vary = true
34//!
35//! [templates]
36//! template_dir = "./templates"
37//! cache_enabled = true
38//! hot_reload = true
39//!
40//! [security]
41//! csrf_enabled = true
42//! session_max_age_secs = 86400
43//! ```
44//!
45//! # Usage
46//!
47//! ```rust
48//! use acton_htmx::config::ActonHtmxConfig;
49//!
50//! // Load default configuration
51//! let config = ActonHtmxConfig::default();
52//!
53//! // Access HTMX-specific config
54//! let timeout = config.htmx.request_timeout_ms;
55//! let csrf_enabled = config.security.csrf_enabled;
56//! ```
57
58use figment::{
59    providers::{Env, Format, Toml},
60    Figment,
61};
62use serde::{Deserialize, Serialize};
63use std::collections::HashMap;
64use std::path::PathBuf;
65use std::time::Duration;
66
67use crate::oauth2::types::OAuthConfig;
68
69/// HTMX-specific configuration
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(default)]
72pub struct HtmxSettings {
73    /// Request timeout in milliseconds
74    pub request_timeout_ms: u64,
75
76    /// Enable HTMX history support
77    pub history_enabled: bool,
78
79    /// Enable auto-vary middleware for caching
80    pub auto_vary: bool,
81
82    /// Enable request guards for HTMX-only routes
83    pub guards_enabled: bool,
84}
85
86impl Default for HtmxSettings {
87    fn default() -> Self {
88        Self {
89            request_timeout_ms: 5000,
90            history_enabled: true,
91            auto_vary: true,
92            guards_enabled: false,
93        }
94    }
95}
96
97/// Template engine configuration
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(default)]
100pub struct TemplateSettings {
101    /// Directory containing Askama templates
102    pub template_dir: PathBuf,
103
104    /// Enable template caching
105    pub cache_enabled: bool,
106
107    /// Enable hot reload in development
108    pub hot_reload: bool,
109
110    /// Template file extensions to watch
111    pub watch_extensions: Vec<String>,
112}
113
114impl Default for TemplateSettings {
115    fn default() -> Self {
116        Self {
117            template_dir: PathBuf::from("./templates"),
118            cache_enabled: true,
119            hot_reload: cfg!(debug_assertions),
120            watch_extensions: vec!["html".to_string(), "jinja".to_string()],
121        }
122    }
123}
124
125/// Security configuration
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(default)]
128pub struct SecuritySettings {
129    /// Enable CSRF protection
130    pub csrf_enabled: bool,
131
132    /// Session maximum age in seconds
133    pub session_max_age_secs: u64,
134
135    /// Enable secure cookies (HTTPS only)
136    pub secure_cookies: bool,
137
138    /// Cookie `SameSite` policy
139    pub same_site: SameSitePolicy,
140
141    /// Enable security headers middleware
142    pub security_headers_enabled: bool,
143
144    /// Rate limiting configuration
145    pub rate_limit: RateLimitConfig,
146}
147
148impl Default for SecuritySettings {
149    fn default() -> Self {
150        Self {
151            csrf_enabled: true,
152            session_max_age_secs: 86400, // 24 hours
153            secure_cookies: !cfg!(debug_assertions),
154            same_site: SameSitePolicy::Lax,
155            security_headers_enabled: true,
156            rate_limit: RateLimitConfig::default(),
157        }
158    }
159}
160
161/// Cookie `SameSite` policy
162#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
163#[serde(rename_all = "lowercase")]
164pub enum SameSitePolicy {
165    /// Strict `SameSite` policy
166    Strict,
167    /// Lax `SameSite` policy (recommended)
168    Lax,
169    /// None `SameSite` policy (requires secure cookies)
170    None,
171}
172
173/// Rate limiting configuration
174///
175/// Supports both Redis-backed (distributed) and in-memory (single instance) rate limiting.
176/// Rate limits can be configured per authenticated user, per IP address, and per specific route patterns.
177///
178/// # Example Configuration
179///
180/// ```toml
181/// [security.rate_limit]
182/// enabled = true
183/// per_user_rpm = 120           # 120 requests per minute for authenticated users
184/// per_ip_rpm = 60              # 60 requests per minute per IP address
185/// per_route_rpm = 30           # 30 requests per minute for specific routes (e.g., auth)
186/// window_secs = 60             # Rate limit window (60 seconds)
187/// redis_enabled = true         # Use Redis for distributed rate limiting
188/// failure_mode = "closed"      # Deny on rate limit errors (strict)
189/// ```
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(default)]
192pub struct RateLimitConfig {
193    /// Enable rate limiting middleware
194    pub enabled: bool,
195
196    /// Requests per minute per authenticated user
197    pub per_user_rpm: u32,
198
199    /// Requests per minute per IP address (for anonymous requests)
200    pub per_ip_rpm: u32,
201
202    /// Requests per minute for specific routes (e.g., auth endpoints)
203    pub per_route_rpm: u32,
204
205    /// Rate limit window in seconds
206    pub window_secs: u64,
207
208    /// Use Redis for distributed rate limiting (requires cache feature)
209    /// Falls back to in-memory if Redis is unavailable
210    pub redis_enabled: bool,
211
212    /// Failure mode when rate limit backend fails
213    /// - Closed: Deny requests when rate limiting fails (strict, production)
214    /// - Open: Allow requests when rate limiting fails (permissive, development)
215    pub failure_mode: RateLimitFailureMode,
216
217    /// Route patterns that should use stricter rate limits (e.g., `"/login"`, `"/register"`)
218    pub strict_routes: Vec<String>,
219}
220
221impl Default for RateLimitConfig {
222    fn default() -> Self {
223        Self {
224            enabled: true,
225            per_user_rpm: 120,
226            per_ip_rpm: 60,
227            per_route_rpm: 30,
228            window_secs: 60,
229            redis_enabled: cfg!(feature = "redis"),
230            failure_mode: RateLimitFailureMode::default(),
231            strict_routes: vec![
232                "/login".to_string(),
233                "/register".to_string(),
234                "/password-reset".to_string(),
235            ],
236        }
237    }
238}
239
240/// Failure mode for rate limit backend errors
241#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
242#[serde(rename_all = "lowercase")]
243pub enum RateLimitFailureMode {
244    /// Deny requests when rate limiting fails (strict, production)
245    Closed,
246    /// Allow requests when rate limiting fails (permissive, development)
247    Open,
248}
249
250impl Default for RateLimitFailureMode {
251    fn default() -> Self {
252        if cfg!(debug_assertions) {
253            Self::Open
254        } else {
255            Self::Closed
256        }
257    }
258}
259
260/// Failure mode for Cedar policy evaluation errors
261#[cfg(feature = "cedar")]
262#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "lowercase")]
264pub enum FailureMode {
265    /// Deny requests when policy evaluation fails (strict, production)
266    Closed,
267    /// Allow requests when policy evaluation fails (permissive, development)
268    Open,
269}
270
271impl Default for FailureMode {
272    fn default() -> Self {
273        if cfg!(debug_assertions) {
274            Self::Open
275        } else {
276            Self::Closed
277        }
278    }
279}
280
281/// Cedar authorization configuration
282///
283/// Configuration for AWS Cedar policy-based authorization.
284/// Cedar provides declarative, human-readable authorization policies
285/// with support for RBAC, ABAC, resource ownership, and attribute-based access control.
286///
287/// # Example Configuration
288///
289/// ```toml
290/// [cedar]
291/// enabled = true
292/// policy_path = "policies/app.cedar"
293/// hot_reload = false
294/// hot_reload_interval_secs = 60
295/// cache_enabled = true
296/// cache_ttl_secs = 300
297/// failure_mode = "closed"  # or "open"
298/// ```
299#[cfg(feature = "cedar")]
300#[derive(Debug, Clone, Serialize, Deserialize)]
301#[serde(default)]
302pub struct CedarConfig {
303    /// Enable Cedar authorization
304    pub enabled: bool,
305
306    /// Path to Cedar policy file
307    pub policy_path: PathBuf,
308
309    /// Enable policy hot-reload (watch file for changes)
310    /// Note: Manual reload via endpoint is recommended in production
311    pub hot_reload: bool,
312
313    /// Hot-reload check interval in seconds
314    pub hot_reload_interval_secs: u64,
315
316    /// Enable policy caching for performance (requires redis feature)
317    pub cache_enabled: bool,
318
319    /// Policy cache TTL in seconds
320    pub cache_ttl_secs: u64,
321
322    /// Failure mode for policy evaluation errors
323    /// - Closed: Deny requests when policy evaluation fails (strict, production)
324    /// - Open: Allow requests when policy evaluation fails (permissive, development)
325    pub failure_mode: FailureMode,
326}
327
328#[cfg(feature = "cedar")]
329impl Default for CedarConfig {
330    fn default() -> Self {
331        Self {
332            enabled: false, // Disabled by default, must be explicitly enabled
333            policy_path: PathBuf::from("policies/app.cedar"),
334            hot_reload: false,
335            hot_reload_interval_secs: 60,
336            cache_enabled: true,
337            cache_ttl_secs: 300,
338            failure_mode: FailureMode::default(), // Open in debug, closed in release
339        }
340    }
341}
342
343#[cfg(feature = "cedar")]
344impl CedarConfig {
345    /// Get hot-reload interval as Duration
346    #[must_use]
347    pub const fn hot_reload_interval(&self) -> Duration {
348        Duration::from_secs(self.hot_reload_interval_secs)
349    }
350
351    /// Get cache TTL as Duration
352    #[must_use]
353    pub const fn cache_ttl(&self) -> Duration {
354        Duration::from_secs(self.cache_ttl_secs)
355    }
356}
357
358/// Complete acton-htmx configuration
359///
360/// Combines framework configuration with HTMX-specific settings.
361/// Uses `#[serde(flatten)]` to merge all fields into a single `config.toml`.
362#[derive(Debug, Clone, Serialize, Deserialize, Default)]
363pub struct ActonHtmxConfig {
364    /// HTMX-specific settings
365    #[serde(default)]
366    pub htmx: HtmxSettings,
367
368    /// Template engine settings
369    #[serde(default)]
370    pub templates: TemplateSettings,
371
372    /// Security settings
373    #[serde(default)]
374    pub security: SecuritySettings,
375
376    /// OAuth2 configuration
377    #[serde(default)]
378    pub oauth2: OAuthConfig,
379
380    /// Cedar authorization configuration (optional, requires cedar feature)
381    #[cfg(feature = "cedar")]
382    #[serde(default)]
383    pub cedar: Option<CedarConfig>,
384
385    /// Feature flags
386    #[serde(default)]
387    pub features: HashMap<String, bool>,
388}
389
390impl ActonHtmxConfig {
391    /// Load configuration for a specific service
392    ///
393    /// Searches for configuration in XDG-compliant locations with precedence:
394    /// 1. Environment variables (`ACTON_*`, use `__` for nesting)
395    /// 2. `./config.toml`
396    /// 3. `~/.config/acton-htmx/{service_name}/config.toml`
397    /// 4. `/etc/acton-htmx/{service_name}/config.toml`
398    /// 5. Defaults
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if:
403    /// - Default configuration cannot be serialized to TOML
404    /// - Configuration file cannot be read or parsed
405    /// - Configuration values fail validation or type conversion
406    /// - Required fields are missing from merged configuration
407    ///
408    /// # Example
409    ///
410    /// ```rust,no_run
411    /// use acton_htmx::config::ActonHtmxConfig;
412    ///
413    /// # fn example() -> anyhow::Result<()> {
414    /// let config = ActonHtmxConfig::load_for_service("my-app")?;
415    /// # Ok(())
416    /// # }
417    /// ```
418    pub fn load_for_service(service_name: &str) -> anyhow::Result<Self> {
419        let mut figment = Figment::new()
420            // 5. Start with defaults (lowest priority)
421            .merge(Toml::string(&toml::to_string(&Self::default())?));
422
423        // 4. System config: /etc/acton-htmx/{service_name}/config.toml
424        let system_config = PathBuf::from("/etc/acton-htmx")
425            .join(service_name)
426            .join("config.toml");
427        if system_config.exists() {
428            figment = figment.merge(Toml::file(&system_config));
429        }
430
431        // 3. User config: ~/.config/acton-htmx/{service_name}/config.toml
432        let user_config = Self::recommended_path(service_name);
433        if user_config.exists() {
434            figment = figment.merge(Toml::file(&user_config));
435        }
436
437        // 2. Local config: ./config.toml
438        let local_config = PathBuf::from("./config.toml");
439        if local_config.exists() {
440            figment = figment.merge(Toml::file(&local_config));
441        }
442
443        // 1. Environment variables (highest priority, double underscore for nesting)
444        figment = figment.merge(Env::prefixed("ACTON_").split("__").lowercase(true));
445
446        let config = figment.extract()?;
447        Ok(config)
448    }
449
450    /// Load configuration from a specific file
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if:
455    /// - Default configuration cannot be serialized to TOML
456    /// - Configuration file at `path` cannot be read or does not exist
457    /// - Configuration file contains invalid TOML syntax
458    /// - Configuration values fail validation or type conversion
459    /// - Required fields are missing
460    ///
461    /// # Example
462    ///
463    /// ```rust,no_run
464    /// use acton_htmx::config::ActonHtmxConfig;
465    ///
466    /// # fn example() -> anyhow::Result<()> {
467    /// let config = ActonHtmxConfig::load_from("./config/production.toml")?;
468    /// # Ok(())
469    /// # }
470    /// ```
471    pub fn load_from(path: &str) -> anyhow::Result<Self> {
472        let config = Figment::new()
473            // Start with defaults
474            .merge(Toml::string(&toml::to_string(&Self::default())?))
475            // Load from specified file (if it exists)
476            .merge(Toml::file(path))
477            // Environment variables override everything (prefix ACTON_, double underscore for nesting)
478            .merge(Env::prefixed("ACTON_").split("__").lowercase(true))
479            .extract()?;
480
481        Ok(config)
482    }
483
484    /// Get the recommended XDG config path for a service
485    ///
486    /// # Example
487    ///
488    /// ```rust
489    /// use acton_htmx::config::ActonHtmxConfig;
490    ///
491    /// let path = ActonHtmxConfig::recommended_path("my-app");
492    /// // Returns: ~/.config/acton-htmx/my-app/config.toml
493    /// ```
494    #[must_use]
495    pub fn recommended_path(service_name: &str) -> PathBuf {
496        dirs::config_dir().map_or_else(
497            || PathBuf::from("./config.toml"),
498            |config_dir| {
499                config_dir
500                    .join("acton-htmx")
501                    .join(service_name)
502                    .join("config.toml")
503            },
504        )
505    }
506
507    /// Create config directory for a service
508    ///
509    /// # Errors
510    ///
511    /// Returns an error if:
512    /// - Directory creation fails due to insufficient permissions
513    /// - Parent directory path is invalid or inaccessible
514    /// - Filesystem I/O error occurs
515    ///
516    /// # Example
517    ///
518    /// ```rust,no_run
519    /// use acton_htmx::config::ActonHtmxConfig;
520    ///
521    /// # fn example() -> anyhow::Result<()> {
522    /// ActonHtmxConfig::create_config_dir("my-app")?;
523    /// # Ok(())
524    /// # }
525    /// ```
526    pub fn create_config_dir(service_name: &str) -> anyhow::Result<PathBuf> {
527        let config_path = Self::recommended_path(service_name);
528        if let Some(parent) = config_path.parent() {
529            std::fs::create_dir_all(parent)?;
530        }
531        Ok(config_path)
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn test_default_config() {
541        let config = ActonHtmxConfig::default();
542        assert_eq!(config.htmx.request_timeout_ms, 5000);
543        assert!(config.htmx.history_enabled);
544        assert!(config.htmx.auto_vary);
545        assert!(config.security.csrf_enabled);
546        assert_eq!(config.security.session_max_age_secs, 86400);
547    }
548
549    #[test]
550    fn test_template_defaults() {
551        let templates = TemplateSettings::default();
552        assert_eq!(templates.template_dir, PathBuf::from("./templates"));
553        assert!(templates.cache_enabled);
554        assert_eq!(templates.watch_extensions, vec!["html", "jinja"]);
555    }
556
557    #[test]
558    fn test_security_defaults() {
559        let security = SecuritySettings::default();
560        assert!(security.csrf_enabled);
561        assert!(security.security_headers_enabled);
562
563        // secure_cookies should be true in release, false in debug
564        #[cfg(debug_assertions)]
565        assert!(!security.secure_cookies);
566
567        #[cfg(not(debug_assertions))]
568        assert!(security.secure_cookies);
569    }
570
571    #[test]
572    fn test_recommended_path() {
573        let path = ActonHtmxConfig::recommended_path("test-app");
574
575        // Should contain the service name
576        assert!(path.to_str().unwrap().contains("test-app"));
577
578        // Should end with config.toml
579        assert!(path.to_str().unwrap().ends_with("config.toml"));
580
581        // Should contain acton-htmx in the path
582        assert!(path.to_str().unwrap().contains("acton-htmx"));
583    }
584
585    #[test]
586    fn test_load_from_nonexistent_file() {
587        use std::env;
588        // Ensure no test env vars from other tests
589        env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
590        env::remove_var("ACTON_HTMX__HISTORY_ENABLED");
591
592        // Should return default config when file doesn't exist
593        let result = ActonHtmxConfig::load_from("/nonexistent/path/config.toml");
594        assert!(result.is_ok());
595
596        let config = result.unwrap();
597        // Should have default values
598        assert_eq!(config.htmx.request_timeout_ms, 5000);
599    }
600
601    #[test]
602    fn test_load_from_toml_file() {
603        use std::env;
604        use std::fs;
605        use std::io::Write;
606
607        // Ensure no test env vars from other tests
608        env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
609        env::remove_var("ACTON_HTMX__HISTORY_ENABLED");
610
611        // Create a temporary config file
612        let temp_dir = std::env::temp_dir();
613        let config_path = temp_dir.join("test_config.toml");
614
615        let toml_content = r"
616[htmx]
617request_timeout_ms = 10000
618history_enabled = false
619
620[security]
621csrf_enabled = false
622session_max_age_secs = 3600
623";
624
625        let mut file = fs::File::create(&config_path).unwrap();
626        file.write_all(toml_content.as_bytes()).unwrap();
627
628        // Load configuration
629        let result = ActonHtmxConfig::load_from(config_path.to_str().unwrap());
630        assert!(result.is_ok());
631
632        let config = result.unwrap();
633        assert_eq!(config.htmx.request_timeout_ms, 10000);
634        assert!(!config.htmx.history_enabled);
635        assert!(!config.security.csrf_enabled);
636        assert_eq!(config.security.session_max_age_secs, 3600);
637
638        // Cleanup
639        fs::remove_file(config_path).ok();
640    }
641
642    #[test]
643    fn test_load_for_service_with_defaults() {
644        use std::env;
645        // Ensure no test env vars from other tests
646        env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
647        env::remove_var("ACTON_HTMX__HISTORY_ENABLED");
648
649        // Loading a service with no config files should return defaults
650        let result = ActonHtmxConfig::load_for_service("nonexistent-service-123");
651        assert!(result.is_ok());
652
653        let config = result.unwrap();
654        assert_eq!(config.htmx.request_timeout_ms, 5000);
655        assert!(config.htmx.history_enabled);
656    }
657
658    #[test]
659    fn test_create_config_dir() {
660        use std::fs;
661
662        let temp_service = format!("test-service-{}", std::process::id());
663
664        // This should create the directory structure
665        let result = ActonHtmxConfig::create_config_dir(&temp_service);
666        assert!(result.is_ok());
667
668        let config_path = result.unwrap();
669
670        // Parent directory should exist
671        if let Some(parent) = config_path.parent() {
672            assert!(parent.exists() || !config_path.to_str().unwrap().starts_with('/'));
673        }
674
675        // Cleanup - try to remove, but don't fail if we can't
676        if let Some(parent) = config_path.parent() {
677            if parent.exists() {
678                fs::remove_dir_all(parent).ok();
679            }
680        }
681    }
682}