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}