acton-htmx 1.0.0-beta.7

Opinionated Rust web framework for HTMX applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
//! Configuration management for acton-htmx
//!
//! Extends acton-service's XDG-compliant configuration system with HTMX-specific
//! settings. Configuration is loaded from multiple sources with clear precedence:
//!
//! 1. Environment variables (highest priority, `ACTON_` prefix, `__` for nesting)
//! 2. `./config.toml` (development)
//! 3. `~/.config/acton-htmx/config.toml` (user config, XDG)
//! 4. `/etc/acton-htmx/config.toml` (system config)
//! 5. Hardcoded defaults (fallback)
//!
//! Environment variable format: `ACTON_SECTION__FIELD_NAME`
//! - Use `__` (double underscore) to separate nested sections
//! - Use `_` (single underscore) within field names
//! - Example: `ACTON_HTMX__REQUEST_TIMEOUT_MS=5000`
//!
//! # Example Configuration
//!
//! ```toml
//! # config.toml
//! [service]
//! name = "my-htmx-app"
//! port = 3000
//!
//! [database]
//! url = "sqlite://./dev.db"
//! optional = true
//! lazy_init = true
//!
//! [htmx]
//! request_timeout_ms = 5000
//! history_enabled = true
//! auto_vary = true
//!
//! [templates]
//! template_dir = "./templates"
//! cache_enabled = true
//! hot_reload = true
//!
//! [security]
//! csrf_enabled = true
//! session_max_age_secs = 86400
//! ```
//!
//! # Usage
//!
//! ```rust
//! use acton_htmx::config::ActonHtmxConfig;
//!
//! // Load default configuration
//! let config = ActonHtmxConfig::default();
//!
//! // Access HTMX-specific config
//! let timeout = config.htmx.request_timeout_ms;
//! let csrf_enabled = config.security.csrf_enabled;
//! ```

use figment::{
    providers::{Env, Format, Toml},
    Figment,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

use crate::oauth2::types::OAuthConfig;

/// HTMX-specific configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HtmxSettings {
    /// Request timeout in milliseconds
    pub request_timeout_ms: u64,

    /// Enable HTMX history support
    pub history_enabled: bool,

    /// Enable auto-vary middleware for caching
    pub auto_vary: bool,

    /// Enable request guards for HTMX-only routes
    pub guards_enabled: bool,
}

impl Default for HtmxSettings {
    fn default() -> Self {
        Self {
            request_timeout_ms: 5000,
            history_enabled: true,
            auto_vary: true,
            guards_enabled: false,
        }
    }
}

/// Template engine configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TemplateSettings {
    /// Directory containing Askama templates
    pub template_dir: PathBuf,

    /// Enable template caching
    pub cache_enabled: bool,

    /// Enable hot reload in development
    pub hot_reload: bool,

    /// Template file extensions to watch
    pub watch_extensions: Vec<String>,
}

impl Default for TemplateSettings {
    fn default() -> Self {
        Self {
            template_dir: PathBuf::from("./templates"),
            cache_enabled: true,
            hot_reload: cfg!(debug_assertions),
            watch_extensions: vec!["html".to_string(), "jinja".to_string()],
        }
    }
}

/// Security configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecuritySettings {
    /// Enable CSRF protection
    pub csrf_enabled: bool,

    /// Session maximum age in seconds
    pub session_max_age_secs: u64,

    /// Enable secure cookies (HTTPS only)
    pub secure_cookies: bool,

    /// Cookie `SameSite` policy
    pub same_site: SameSitePolicy,

    /// Enable security headers middleware
    pub security_headers_enabled: bool,

    /// Rate limiting configuration
    pub rate_limit: RateLimitConfig,
}

impl Default for SecuritySettings {
    fn default() -> Self {
        Self {
            csrf_enabled: true,
            session_max_age_secs: 86400, // 24 hours
            secure_cookies: !cfg!(debug_assertions),
            same_site: SameSitePolicy::Lax,
            security_headers_enabled: true,
            rate_limit: RateLimitConfig::default(),
        }
    }
}

/// Cookie `SameSite` policy
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SameSitePolicy {
    /// Strict `SameSite` policy
    Strict,
    /// Lax `SameSite` policy (recommended)
    Lax,
    /// None `SameSite` policy (requires secure cookies)
    None,
}

/// Rate limiting configuration
///
/// Supports both Redis-backed (distributed) and in-memory (single instance) rate limiting.
/// Rate limits can be configured per authenticated user, per IP address, and per specific route patterns.
///
/// # Example Configuration
///
/// ```toml
/// [security.rate_limit]
/// enabled = true
/// per_user_rpm = 120           # 120 requests per minute for authenticated users
/// per_ip_rpm = 60              # 60 requests per minute per IP address
/// per_route_rpm = 30           # 30 requests per minute for specific routes (e.g., auth)
/// window_secs = 60             # Rate limit window (60 seconds)
/// redis_enabled = true         # Use Redis for distributed rate limiting
/// failure_mode = "closed"      # Deny on rate limit errors (strict)
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitConfig {
    /// Enable rate limiting middleware
    pub enabled: bool,

    /// Requests per minute per authenticated user
    pub per_user_rpm: u32,

    /// Requests per minute per IP address (for anonymous requests)
    pub per_ip_rpm: u32,

    /// Requests per minute for specific routes (e.g., auth endpoints)
    pub per_route_rpm: u32,

    /// Rate limit window in seconds
    pub window_secs: u64,

    /// Use Redis for distributed rate limiting (requires cache feature)
    /// Falls back to in-memory if Redis is unavailable
    pub redis_enabled: bool,

    /// Failure mode when rate limit backend fails
    /// - Closed: Deny requests when rate limiting fails (strict, production)
    /// - Open: Allow requests when rate limiting fails (permissive, development)
    pub failure_mode: RateLimitFailureMode,

    /// Route patterns that should use stricter rate limits (e.g., `"/login"`, `"/register"`)
    pub strict_routes: Vec<String>,
}

impl Default for RateLimitConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            per_user_rpm: 120,
            per_ip_rpm: 60,
            per_route_rpm: 30,
            window_secs: 60,
            redis_enabled: cfg!(feature = "redis"),
            failure_mode: RateLimitFailureMode::default(),
            strict_routes: vec![
                "/login".to_string(),
                "/register".to_string(),
                "/password-reset".to_string(),
            ],
        }
    }
}

/// Failure mode for rate limit backend errors
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RateLimitFailureMode {
    /// Deny requests when rate limiting fails (strict, production)
    Closed,
    /// Allow requests when rate limiting fails (permissive, development)
    Open,
}

impl Default for RateLimitFailureMode {
    fn default() -> Self {
        if cfg!(debug_assertions) {
            Self::Open
        } else {
            Self::Closed
        }
    }
}

/// Failure mode for Cedar policy evaluation errors
#[cfg(feature = "cedar")]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FailureMode {
    /// Deny requests when policy evaluation fails (strict, production)
    Closed,
    /// Allow requests when policy evaluation fails (permissive, development)
    Open,
}

impl Default for FailureMode {
    fn default() -> Self {
        if cfg!(debug_assertions) {
            Self::Open
        } else {
            Self::Closed
        }
    }
}

/// Cedar authorization configuration
///
/// Configuration for AWS Cedar policy-based authorization.
/// Cedar provides declarative, human-readable authorization policies
/// with support for RBAC, ABAC, resource ownership, and attribute-based access control.
///
/// # Example Configuration
///
/// ```toml
/// [cedar]
/// enabled = true
/// policy_path = "policies/app.cedar"
/// hot_reload = false
/// hot_reload_interval_secs = 60
/// cache_enabled = true
/// cache_ttl_secs = 300
/// failure_mode = "closed"  # or "open"
/// ```
#[cfg(feature = "cedar")]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CedarConfig {
    /// Enable Cedar authorization
    pub enabled: bool,

    /// Path to Cedar policy file
    pub policy_path: PathBuf,

    /// Enable policy hot-reload (watch file for changes)
    /// Note: Manual reload via endpoint is recommended in production
    pub hot_reload: bool,

    /// Hot-reload check interval in seconds
    pub hot_reload_interval_secs: u64,

    /// Enable policy caching for performance (requires redis feature)
    pub cache_enabled: bool,

    /// Policy cache TTL in seconds
    pub cache_ttl_secs: u64,

    /// Failure mode for policy evaluation errors
    /// - Closed: Deny requests when policy evaluation fails (strict, production)
    /// - Open: Allow requests when policy evaluation fails (permissive, development)
    pub failure_mode: FailureMode,
}

#[cfg(feature = "cedar")]
impl Default for CedarConfig {
    fn default() -> Self {
        Self {
            enabled: false, // Disabled by default, must be explicitly enabled
            policy_path: PathBuf::from("policies/app.cedar"),
            hot_reload: false,
            hot_reload_interval_secs: 60,
            cache_enabled: true,
            cache_ttl_secs: 300,
            failure_mode: FailureMode::default(), // Open in debug, closed in release
        }
    }
}

#[cfg(feature = "cedar")]
impl CedarConfig {
    /// Get hot-reload interval as Duration
    #[must_use]
    pub const fn hot_reload_interval(&self) -> Duration {
        Duration::from_secs(self.hot_reload_interval_secs)
    }

    /// Get cache TTL as Duration
    #[must_use]
    pub const fn cache_ttl(&self) -> Duration {
        Duration::from_secs(self.cache_ttl_secs)
    }
}

/// Complete acton-htmx configuration
///
/// Combines framework configuration with HTMX-specific settings.
/// Uses `#[serde(flatten)]` to merge all fields into a single `config.toml`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActonHtmxConfig {
    /// HTMX-specific settings
    #[serde(default)]
    pub htmx: HtmxSettings,

    /// Template engine settings
    #[serde(default)]
    pub templates: TemplateSettings,

    /// Security settings
    #[serde(default)]
    pub security: SecuritySettings,

    /// OAuth2 configuration
    #[serde(default)]
    pub oauth2: OAuthConfig,

    /// Cedar authorization configuration (optional, requires cedar feature)
    #[cfg(feature = "cedar")]
    #[serde(default)]
    pub cedar: Option<CedarConfig>,

    /// Feature flags
    #[serde(default)]
    pub features: HashMap<String, bool>,
}

impl ActonHtmxConfig {
    /// Load configuration for a specific service
    ///
    /// Searches for configuration in XDG-compliant locations with precedence:
    /// 1. Environment variables (`ACTON_*`, use `__` for nesting)
    /// 2. `./config.toml`
    /// 3. `~/.config/acton-htmx/{service_name}/config.toml`
    /// 4. `/etc/acton-htmx/{service_name}/config.toml`
    /// 5. Defaults
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Default configuration cannot be serialized to TOML
    /// - Configuration file cannot be read or parsed
    /// - Configuration values fail validation or type conversion
    /// - Required fields are missing from merged configuration
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// use acton_htmx::config::ActonHtmxConfig;
    ///
    /// # fn example() -> anyhow::Result<()> {
    /// let config = ActonHtmxConfig::load_for_service("my-app")?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn load_for_service(service_name: &str) -> anyhow::Result<Self> {
        let mut figment = Figment::new()
            // 5. Start with defaults (lowest priority)
            .merge(Toml::string(&toml::to_string(&Self::default())?));

        // 4. System config: /etc/acton-htmx/{service_name}/config.toml
        let system_config = PathBuf::from("/etc/acton-htmx")
            .join(service_name)
            .join("config.toml");
        if system_config.exists() {
            figment = figment.merge(Toml::file(&system_config));
        }

        // 3. User config: ~/.config/acton-htmx/{service_name}/config.toml
        let user_config = Self::recommended_path(service_name);
        if user_config.exists() {
            figment = figment.merge(Toml::file(&user_config));
        }

        // 2. Local config: ./config.toml
        let local_config = PathBuf::from("./config.toml");
        if local_config.exists() {
            figment = figment.merge(Toml::file(&local_config));
        }

        // 1. Environment variables (highest priority, double underscore for nesting)
        figment = figment.merge(Env::prefixed("ACTON_").split("__").lowercase(true));

        let config = figment.extract()?;
        Ok(config)
    }

    /// Load configuration from a specific file
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Default configuration cannot be serialized to TOML
    /// - Configuration file at `path` cannot be read or does not exist
    /// - Configuration file contains invalid TOML syntax
    /// - Configuration values fail validation or type conversion
    /// - Required fields are missing
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// use acton_htmx::config::ActonHtmxConfig;
    ///
    /// # fn example() -> anyhow::Result<()> {
    /// let config = ActonHtmxConfig::load_from("./config/production.toml")?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn load_from(path: &str) -> anyhow::Result<Self> {
        let config = Figment::new()
            // Start with defaults
            .merge(Toml::string(&toml::to_string(&Self::default())?))
            // Load from specified file (if it exists)
            .merge(Toml::file(path))
            // Environment variables override everything (prefix ACTON_, double underscore for nesting)
            .merge(Env::prefixed("ACTON_").split("__").lowercase(true))
            .extract()?;

        Ok(config)
    }

    /// Get the recommended XDG config path for a service
    ///
    /// # Example
    ///
    /// ```rust
    /// use acton_htmx::config::ActonHtmxConfig;
    ///
    /// let path = ActonHtmxConfig::recommended_path("my-app");
    /// // Returns: ~/.config/acton-htmx/my-app/config.toml
    /// ```
    #[must_use]
    pub fn recommended_path(service_name: &str) -> PathBuf {
        dirs::config_dir().map_or_else(
            || PathBuf::from("./config.toml"),
            |config_dir| {
                config_dir
                    .join("acton-htmx")
                    .join(service_name)
                    .join("config.toml")
            },
        )
    }

    /// Create config directory for a service
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Directory creation fails due to insufficient permissions
    /// - Parent directory path is invalid or inaccessible
    /// - Filesystem I/O error occurs
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// use acton_htmx::config::ActonHtmxConfig;
    ///
    /// # fn example() -> anyhow::Result<()> {
    /// ActonHtmxConfig::create_config_dir("my-app")?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn create_config_dir(service_name: &str) -> anyhow::Result<PathBuf> {
        let config_path = Self::recommended_path(service_name);
        if let Some(parent) = config_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        Ok(config_path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = ActonHtmxConfig::default();
        assert_eq!(config.htmx.request_timeout_ms, 5000);
        assert!(config.htmx.history_enabled);
        assert!(config.htmx.auto_vary);
        assert!(config.security.csrf_enabled);
        assert_eq!(config.security.session_max_age_secs, 86400);
    }

    #[test]
    fn test_template_defaults() {
        let templates = TemplateSettings::default();
        assert_eq!(templates.template_dir, PathBuf::from("./templates"));
        assert!(templates.cache_enabled);
        assert_eq!(templates.watch_extensions, vec!["html", "jinja"]);
    }

    #[test]
    fn test_security_defaults() {
        let security = SecuritySettings::default();
        assert!(security.csrf_enabled);
        assert!(security.security_headers_enabled);

        // secure_cookies should be true in release, false in debug
        #[cfg(debug_assertions)]
        assert!(!security.secure_cookies);

        #[cfg(not(debug_assertions))]
        assert!(security.secure_cookies);
    }

    #[test]
    fn test_recommended_path() {
        let path = ActonHtmxConfig::recommended_path("test-app");

        // Should contain the service name
        assert!(path.to_str().unwrap().contains("test-app"));

        // Should end with config.toml
        assert!(path.to_str().unwrap().ends_with("config.toml"));

        // Should contain acton-htmx in the path
        assert!(path.to_str().unwrap().contains("acton-htmx"));
    }

    #[test]
    fn test_load_from_nonexistent_file() {
        use std::env;
        // Ensure no test env vars from other tests
        env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
        env::remove_var("ACTON_HTMX__HISTORY_ENABLED");

        // Should return default config when file doesn't exist
        let result = ActonHtmxConfig::load_from("/nonexistent/path/config.toml");
        assert!(result.is_ok());

        let config = result.unwrap();
        // Should have default values
        assert_eq!(config.htmx.request_timeout_ms, 5000);
    }

    #[test]
    fn test_load_from_toml_file() {
        use std::env;
        use std::fs;
        use std::io::Write;

        // Ensure no test env vars from other tests
        env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
        env::remove_var("ACTON_HTMX__HISTORY_ENABLED");

        // Create a temporary config file
        let temp_dir = std::env::temp_dir();
        let config_path = temp_dir.join("test_config.toml");

        let toml_content = r"
[htmx]
request_timeout_ms = 10000
history_enabled = false

[security]
csrf_enabled = false
session_max_age_secs = 3600
";

        let mut file = fs::File::create(&config_path).unwrap();
        file.write_all(toml_content.as_bytes()).unwrap();

        // Load configuration
        let result = ActonHtmxConfig::load_from(config_path.to_str().unwrap());
        assert!(result.is_ok());

        let config = result.unwrap();
        assert_eq!(config.htmx.request_timeout_ms, 10000);
        assert!(!config.htmx.history_enabled);
        assert!(!config.security.csrf_enabled);
        assert_eq!(config.security.session_max_age_secs, 3600);

        // Cleanup
        fs::remove_file(config_path).ok();
    }

    #[test]
    fn test_load_for_service_with_defaults() {
        use std::env;
        // Ensure no test env vars from other tests
        env::remove_var("ACTON_HTMX__REQUEST_TIMEOUT_MS");
        env::remove_var("ACTON_HTMX__HISTORY_ENABLED");

        // Loading a service with no config files should return defaults
        let result = ActonHtmxConfig::load_for_service("nonexistent-service-123");
        assert!(result.is_ok());

        let config = result.unwrap();
        assert_eq!(config.htmx.request_timeout_ms, 5000);
        assert!(config.htmx.history_enabled);
    }

    #[test]
    fn test_create_config_dir() {
        use std::fs;

        let temp_service = format!("test-service-{}", std::process::id());

        // This should create the directory structure
        let result = ActonHtmxConfig::create_config_dir(&temp_service);
        assert!(result.is_ok());

        let config_path = result.unwrap();

        // Parent directory should exist
        if let Some(parent) = config_path.parent() {
            assert!(parent.exists() || !config_path.to_str().unwrap().starts_with('/'));
        }

        // Cleanup - try to remove, but don't fail if we can't
        if let Some(parent) = config_path.parent() {
            if parent.exists() {
                fs::remove_dir_all(parent).ok();
            }
        }
    }
}