Skip to main content

what_core/
config.rs

1//! Configuration handling for What projects
2//!
3//! Parses what.toml files (legacy name wwwhat.toml still supported) that
4//! define server settings, data sources, and caching.
5
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::Result;
11
12/// Main configuration structure parsed from what.toml
13#[derive(Debug, Deserialize, Default, Clone)]
14pub struct Config {
15    /// Server configuration
16    #[serde(default)]
17    pub server: ServerConfig,
18
19    /// Central data store definitions
20    #[serde(default)]
21    pub data: HashMap<String, DataSource>,
22
23    /// Cache configuration
24    #[serde(default)]
25    pub cache: CacheConfig,
26
27    /// Session configuration
28    #[serde(default)]
29    pub session: SessionConfig,
30
31    /// Authentication configuration
32    #[serde(default)]
33    pub auth: AuthConfig,
34
35    /// Upload configuration
36    #[serde(default)]
37    pub uploads: UploadConfig,
38
39    /// Database configuration (when absent, auto-defaults to SQLite)
40    #[serde(default)]
41    pub database: Option<DatabaseConfig>,
42
43    /// Rate limiting configuration
44    #[serde(default)]
45    pub rate_limit: RateLimitConfig,
46
47    /// Email configuration
48    #[serde(default)]
49    pub email: Option<EmailConfig>,
50
51    /// Global redirect rules: old path → new path
52    /// Supports exact matches and wildcard prefixes ("/old/*" → "/new")
53    #[serde(default)]
54    pub redirects: HashMap<String, String>,
55
56    /// Strict mode: warn about unresolved template variables
57    #[serde(default)]
58    pub strict: bool,
59
60    /// Cloudflare configuration (shared credentials for D1, R2, Turnstile)
61    #[serde(default)]
62    pub cloudflare: Option<CloudflareConfig>,
63
64    /// Supabase configuration
65    #[serde(default)]
66    pub supabase: Option<SupabaseConfig>,
67
68    /// Named datasources — multiple backends accessible via `dsn:name` in fetch directives
69    #[serde(default)]
70    pub datasources: HashMap<String, DatasourceConfig>,
71
72    /// Collection authorization policies — one entry per `[collections.<name>]`.
73    /// Collections without an entry get the implicit owner-protected default:
74    /// create = "all", update/delete = "owner", read = "all", owner = "auto".
75    #[serde(default)]
76    pub collections: HashMap<String, CollectionPolicyConfig>,
77}
78
79/// Raw per-collection authorization policy — one per `[collections.name]` in what.toml.
80/// Semantic validation (reserved words, invalid combinations) happens when the
81/// PolicyRegistry is built at startup, so errors fail loud with the collection name.
82///
83/// # Example (what.toml)
84/// ```toml
85/// [collections.notes]
86/// create = "all"
87/// update = "owner"
88/// delete = "owner, admin"
89/// read   = "owner"
90///
91/// [collections.orders]
92/// filter = "org_id=#user.org_id#"
93/// fields.private = ["internal_margin"]
94/// ```
95#[derive(Debug, Deserialize, Clone, Default)]
96pub struct CollectionPolicyConfig {
97    /// Ownership mode: "auto" (default — stamp _owner on create) or "none"
98    pub owner: Option<String>,
99
100    /// Who may create records: "all" | "user" | "none" | role list ("editor, admin")
101    pub create: Option<String>,
102
103    /// Who may update records: adds "owner" to the create vocabulary
104    pub update: Option<String>,
105
106    /// Who may delete records
107    pub delete: Option<String>,
108
109    /// Who may read records — owner/user/roles force a WHERE scope on every fetch
110    pub read: Option<String>,
111
112    /// Forced filter AND-ed into every read and checked on mutations.
113    /// Supports `#user.*#` / `#session.*#` interpolation, e.g. "org_id=#user.org_id#"
114    pub filter: Option<String>,
115
116    /// Field-level rules
117    #[serde(default)]
118    pub fields: FieldRulesConfig,
119}
120
121/// Field-level policy rules for a collection
122#[derive(Debug, Deserialize, Clone, Default)]
123pub struct FieldRulesConfig {
124    /// Fields stripped from client create/update input (server-managed values)
125    #[serde(default)]
126    pub readonly: Vec<String>,
127
128    /// Fields stripped from records before they reach template context
129    #[serde(default)]
130    pub private: Vec<String>,
131}
132
133/// Named datasource configuration — one entry per `[datasources.name]` in what.toml
134///
135/// Supports multiple backend types:
136/// - `"api"` — REST API with base URL and optional headers
137/// - `"d1"` — Cloudflare D1 database
138/// - `"supabase"` — Supabase (PostgREST)
139/// - `"sqlite"` — Local SQLite database
140///
141/// # Example (what.toml)
142/// ```toml
143/// [datasources.users]
144/// type = "supabase"
145/// project_url = "${SUPABASE_URL}"
146/// api_key = "${SUPABASE_KEY}"
147///
148/// [datasources.inventory]
149/// type = "api"
150/// url = "https://inventory.example.com"
151/// headers = { Authorization = "Bearer ${API_TOKEN}" }
152/// ```
153/// Backend type for a named datasource
154#[derive(Debug, Deserialize, Clone, PartialEq)]
155#[serde(rename_all = "lowercase")]
156pub enum DatasourceType {
157    Api,
158    D1,
159    Supabase,
160    Sqlite,
161}
162
163#[derive(Debug, Deserialize, Clone)]
164pub struct DatasourceConfig {
165    /// Backend type
166    pub r#type: DatasourceType,
167
168    /// Base URL for API datasources
169    pub url: Option<String>,
170
171    /// HTTP headers for API datasources (key-value pairs, supports `${ENV_VAR}`)
172    #[serde(default)]
173    pub headers: Option<HashMap<String, String>>,
174
175    /// Cloudflare account ID (D1 datasources)
176    pub account_id: Option<String>,
177
178    /// Cloudflare API token (D1 datasources)
179    pub api_token: Option<String>,
180
181    /// D1 database ID (D1 datasources)
182    pub d1_database_id: Option<String>,
183
184    /// Supabase project URL (Supabase datasources)
185    pub project_url: Option<String>,
186
187    /// Supabase API key (Supabase datasources)
188    pub api_key: Option<String>,
189
190    /// Path to SQLite database file (SQLite datasources)
191    pub path: Option<String>,
192}
193
194/// Unified Cloudflare configuration — credentials shared across D1, R2, Turnstile
195#[derive(Debug, Deserialize, Clone)]
196pub struct CloudflareConfig {
197    /// Cloudflare account ID
198    pub account_id: String,
199
200    /// API token (default for all services, overridable per-service)
201    pub api_token: String,
202
203    /// D1 database ID (enables `type = "d1"` in [database])
204    pub d1_database_id: Option<String>,
205
206    /// R2 bucket name (enables `provider = "r2"` in [uploads])
207    pub r2_bucket: Option<String>,
208
209    /// R2 public URL prefix (e.g., "https://pub-xxx.r2.dev")
210    pub r2_public_url: Option<String>,
211
212    /// Turnstile site key (public, used in <what-turnstile> component)
213    pub turnstile_site_key: Option<String>,
214
215    /// Turnstile secret key (server-side verification)
216    pub turnstile_secret_key: Option<String>,
217}
218
219/// Supabase configuration
220#[derive(Debug, Deserialize, Clone)]
221pub struct SupabaseConfig {
222    /// Supabase project URL (e.g., "https://xxx.supabase.co")
223    pub project_url: String,
224
225    /// Supabase service_role key (NOT the anon key — bypasses Row Level Security)
226    pub api_key: String,
227}
228
229/// Database configuration
230#[derive(Debug, Deserialize, Clone)]
231pub struct DatabaseConfig {
232    /// Database type: "sqlite", "d1", or "supabase"
233    #[serde(default = "default_db_type")]
234    pub r#type: String,
235
236    /// Path to the database file (relative to project root, SQLite only)
237    #[serde(default = "default_db_path")]
238    pub path: String,
239}
240
241fn default_db_type() -> String {
242    "sqlite".to_string()
243}
244
245fn default_db_path() -> String {
246    "data/app.db".to_string()
247}
248
249/// Server configuration
250#[derive(Debug, Deserialize, Clone)]
251pub struct ServerConfig {
252    /// Port to listen on
253    #[serde(default = "default_port")]
254    pub port: u16,
255
256    /// Host to bind to
257    #[serde(default = "default_host")]
258    pub host: String,
259
260    /// Maximum request body size (e.g., "10mb", "500kb", "1gb")
261    #[serde(default = "default_max_body_size")]
262    pub max_body_size: String,
263
264    /// Timeout for external fetch requests in seconds
265    #[serde(default = "default_fetch_timeout")]
266    pub fetch_timeout: u64,
267
268    /// Enable the source viewer endpoint in production mode
269    #[serde(default)]
270    pub source_viewer: bool,
271
272    /// Framework stylesheet mode: "full" (default — the whole design system),
273    /// "minimal" (reset, theme variables, and utilities only — no component styles),
274    /// or "none" (what.css is not auto-injected; bring your own CSS).
275    #[serde(default = "default_css_mode")]
276    pub css: String,
277}
278
279impl Default for ServerConfig {
280    fn default() -> Self {
281        Self {
282            port: default_port(),
283            host: default_host(),
284            max_body_size: default_max_body_size(),
285            fetch_timeout: default_fetch_timeout(),
286            source_viewer: false,
287            css: default_css_mode(),
288        }
289    }
290}
291
292fn default_css_mode() -> String {
293    "full".to_string()
294}
295
296fn default_fetch_timeout() -> u64 {
297    10
298}
299
300fn default_max_body_size() -> String {
301    "10mb".to_string()
302}
303
304fn default_port() -> u16 {
305    8085
306}
307
308fn default_host() -> String {
309    "127.0.0.1".to_string()
310}
311
312/// Data source definition for the central data store
313#[derive(Debug, Deserialize, Clone)]
314#[serde(untagged)]
315pub enum DataSource {
316    /// URL-based data source (external API)
317    Url {
318        url: String,
319        #[serde(default = "default_cache_ttl")]
320        cache: u64,
321    },
322    /// File-based data source (local JSON file)
323    File {
324        file: String,
325        #[serde(default = "default_cache_ttl")]
326        cache: u64,
327    },
328    /// Simple string path (shorthand for file)
329    SimplePath(String),
330}
331
332fn default_cache_ttl() -> u64 {
333    300 // 5 minutes
334}
335
336/// Cache configuration
337#[derive(Debug, Deserialize, Clone)]
338pub struct CacheConfig {
339    /// Whether caching is enabled
340    #[serde(default = "default_cache_enabled")]
341    pub enabled: bool,
342
343    /// Default TTL in seconds
344    #[serde(default = "default_cache_ttl")]
345    pub ttl: u64,
346
347    /// Redis URL (optional, uses memory cache if not set)
348    pub redis_url: Option<String>,
349}
350
351impl Default for CacheConfig {
352    fn default() -> Self {
353        Self {
354            enabled: default_cache_enabled(),
355            ttl: default_cache_ttl(),
356            redis_url: None,
357        }
358    }
359}
360
361fn default_cache_enabled() -> bool {
362    true
363}
364
365/// Session configuration
366#[derive(Debug, Deserialize, Clone)]
367pub struct SessionConfig {
368    /// Whether sessions are enabled
369    #[serde(default = "default_session_enabled")]
370    pub enabled: bool,
371
372    /// Storage backend: "sqlite" (default) or "cloudflare-kv"
373    #[serde(default = "default_session_store")]
374    pub store: String,
375
376    /// Cookie name for the session ID
377    #[serde(default = "default_cookie_name")]
378    pub cookie_name: String,
379
380    /// Session max age in seconds (default: 7 days)
381    #[serde(default = "default_session_max_age")]
382    pub max_age: i64,
383
384    /// Whether to use Secure flag on cookie (defaults to true for production safety)
385    #[serde(default = "default_session_secure")]
386    pub secure: bool,
387
388    /// SQLite database file for sessions (used when store = "sqlite")
389    #[serde(default = "default_session_database")]
390    pub database: String,
391
392    /// Cloudflare KV configuration (used when store = "cloudflare-kv")
393    #[serde(default)]
394    pub cloudflare: Option<CloudflareKvConfig>,
395}
396
397/// Cloudflare Workers KV configuration
398#[derive(Debug, Deserialize, Clone)]
399pub struct CloudflareKvConfig {
400    /// Cloudflare account ID
401    pub account_id: String,
402    /// KV namespace ID
403    pub namespace_id: String,
404    /// API token with KV read/write permissions
405    pub api_token: String,
406}
407
408impl Default for SessionConfig {
409    fn default() -> Self {
410        Self {
411            enabled: default_session_enabled(),
412            store: default_session_store(),
413            cookie_name: default_cookie_name(),
414            max_age: default_session_max_age(),
415            secure: default_session_secure(),
416            database: default_session_database(),
417            cloudflare: None,
418        }
419    }
420}
421
422fn default_session_enabled() -> bool {
423    true
424}
425
426fn default_session_store() -> String {
427    "sqlite".to_string()
428}
429
430fn default_cookie_name() -> String {
431    "w_session".to_string()
432}
433
434fn default_session_max_age() -> i64 {
435    604800 // 7 days in seconds
436}
437
438fn default_session_secure() -> bool {
439    true
440}
441
442fn default_session_database() -> String {
443    "sessions.db".to_string()
444}
445
446/// Authentication configuration
447#[derive(Debug, Deserialize, Clone)]
448pub struct AuthConfig {
449    /// Whether authentication is enabled
450    #[serde(default)]
451    pub enabled: bool,
452
453    /// Backend API URL for authentication (e.g., "https://api.example.com/auth/login")
454    pub login_endpoint: Option<String>,
455
456    /// Backend API URL for logout (optional)
457    pub logout_endpoint: Option<String>,
458
459    /// Cookie name for the JWT token
460    #[serde(default = "default_jwt_cookie_name")]
461    pub jwt_cookie_name: String,
462
463    /// Path to redirect to after login
464    #[serde(default = "default_after_login")]
465    pub after_login: String,
466
467    /// Path to the login page
468    #[serde(default = "default_login_path")]
469    pub login_path: String,
470
471    /// Paths that require authentication (glob patterns)
472    #[serde(default)]
473    pub protected_paths: Vec<String>,
474
475    /// JWT secret for validation (optional - if not set, JWT is decoded but not verified)
476    pub jwt_secret: Option<String>,
477
478    /// JWT claims to extract and make available in templates
479    #[serde(default = "default_jwt_claims")]
480    pub jwt_claims: Vec<String>,
481}
482
483impl Default for AuthConfig {
484    fn default() -> Self {
485        Self {
486            enabled: false,
487            login_endpoint: None,
488            logout_endpoint: None,
489            jwt_cookie_name: default_jwt_cookie_name(),
490            after_login: default_after_login(),
491            login_path: default_login_path(),
492            protected_paths: vec![],
493            jwt_secret: None,
494            jwt_claims: default_jwt_claims(),
495        }
496    }
497}
498
499fn default_jwt_cookie_name() -> String {
500    "w_token".to_string()
501}
502
503fn default_after_login() -> String {
504    "/".to_string()
505}
506
507fn default_login_path() -> String {
508    "/login".to_string()
509}
510
511fn default_jwt_claims() -> Vec<String> {
512    vec![
513        "id".to_string(),
514        "user_id".to_string(),
515        "email".to_string(),
516        "full_name".to_string(),
517    ]
518}
519
520/// Upload configuration
521#[derive(Debug, Deserialize, Clone)]
522pub struct UploadConfig {
523    /// Whether file uploads are enabled
524    #[serde(default)]
525    pub enabled: bool,
526
527    /// Storage provider: "local" (default) or "r2"
528    #[serde(default = "default_upload_provider")]
529    pub provider: String,
530
531    /// Directory for uploaded files (relative to project root, local only)
532    #[serde(default = "default_upload_directory")]
533    pub directory: String,
534
535    /// Maximum file size (e.g., "10mb", "500kb", "1gb")
536    #[serde(default = "default_upload_max_size")]
537    pub max_size: String,
538
539    /// Allowed MIME types (supports wildcards like "image/*")
540    #[serde(default)]
541    pub allowed_types: Vec<String>,
542}
543
544impl Default for UploadConfig {
545    fn default() -> Self {
546        Self {
547            enabled: false,
548            provider: default_upload_provider(),
549            directory: default_upload_directory(),
550            max_size: default_upload_max_size(),
551            allowed_types: vec![],
552        }
553    }
554}
555
556fn default_upload_provider() -> String {
557    "local".to_string()
558}
559
560fn default_upload_directory() -> String {
561    "uploads".to_string()
562}
563
564fn default_upload_max_size() -> String {
565    "10mb".to_string()
566}
567
568impl UploadConfig {
569    /// Parse the max_size string ("10mb", "500kb", "1gb") into bytes
570    pub fn max_size_bytes(&self) -> usize {
571        parse_size_string(&self.max_size)
572    }
573
574    /// Check if a MIME type is allowed by the configuration
575    pub fn is_type_allowed(&self, content_type: &str) -> bool {
576        if self.allowed_types.is_empty() {
577            return true; // No restrictions = allow all
578        }
579        for allowed in &self.allowed_types {
580            if allowed == content_type {
581                return true;
582            }
583            // Wildcard match: "image/*" matches "image/png"
584            if allowed.ends_with("/*") {
585                let prefix = &allowed[..allowed.len() - 1];
586                if content_type.starts_with(prefix) {
587                    return true;
588                }
589            }
590            // Extension match: ".pdf" matches common MIME types
591            if allowed.starts_with('.') {
592                if mime_matches_extension(content_type, allowed) {
593                    return true;
594                }
595            }
596        }
597        false
598    }
599}
600
601/// Parse human-readable size strings into bytes
602pub fn parse_size_string(s: &str) -> usize {
603    let s = s.trim().to_lowercase();
604    if let Some(num) = s.strip_suffix("gb") {
605        num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024
606    } else if let Some(num) = s.strip_suffix("mb") {
607        num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024
608    } else if let Some(num) = s.strip_suffix("kb") {
609        num.trim().parse::<usize>().unwrap_or(0) * 1024
610    } else {
611        s.parse::<usize>().unwrap_or(10 * 1024 * 1024) // default 10MB
612    }
613}
614
615fn mime_matches_extension(content_type: &str, extension: &str) -> bool {
616    match extension {
617        ".pdf" => content_type == "application/pdf",
618        ".doc" => content_type == "application/msword",
619        ".docx" => {
620            content_type
621                == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
622        }
623        ".xls" => content_type == "application/vnd.ms-excel",
624        ".xlsx" => {
625            content_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
626        }
627        ".csv" => content_type == "text/csv",
628        ".txt" => content_type == "text/plain",
629        ".zip" => content_type == "application/zip",
630        _ => false,
631    }
632}
633
634/// Rate limiting configuration
635#[derive(Debug, Deserialize, Clone)]
636pub struct RateLimitConfig {
637    /// Whether rate limiting is enabled
638    #[serde(default = "default_rate_limit_enabled")]
639    pub enabled: bool,
640
641    /// Login endpoint limit: "requests/seconds" (e.g., "5/60" = 5 per 60s)
642    #[serde(default = "default_rate_limit_login")]
643    pub login: String,
644
645    /// Upload endpoint limit: "requests/seconds"
646    #[serde(default = "default_rate_limit_upload")]
647    pub upload: String,
648
649    /// Action endpoint limit: "requests/seconds"
650    #[serde(default = "default_rate_limit_action")]
651    pub action: String,
652}
653
654impl Default for RateLimitConfig {
655    fn default() -> Self {
656        Self {
657            enabled: default_rate_limit_enabled(),
658            login: default_rate_limit_login(),
659            upload: default_rate_limit_upload(),
660            action: default_rate_limit_action(),
661        }
662    }
663}
664
665fn default_rate_limit_enabled() -> bool {
666    true
667}
668
669fn default_rate_limit_login() -> String {
670    "5/60".to_string()
671}
672
673fn default_rate_limit_upload() -> String {
674    "10/60".to_string()
675}
676
677fn default_rate_limit_action() -> String {
678    "30/60".to_string()
679}
680
681impl RateLimitConfig {
682    /// Parse a rate limit string like "5/60" into (max_requests, window_seconds)
683    pub fn parse_limit(s: &str) -> (u32, u64) {
684        let parts: Vec<&str> = s.split('/').collect();
685        if parts.len() == 2 {
686            let max = parts[0].trim().parse().unwrap_or(5);
687            let window = parts[1].trim().parse().unwrap_or(60);
688            (max, window)
689        } else {
690            (5, 60)
691        }
692    }
693}
694
695/// Email configuration
696#[derive(Debug, Deserialize, Clone)]
697pub struct EmailConfig {
698    /// Sender email address
699    pub from: String,
700
701    /// Sender display name (optional)
702    #[serde(default)]
703    pub from_name: Option<String>,
704
705    /// SMTP transport settings (mutually exclusive with `api`)
706    pub smtp: Option<SmtpConfig>,
707
708    /// API transport settings — e.g. Resend (mutually exclusive with `smtp`)
709    pub api: Option<EmailApiConfig>,
710
711    /// Template directory relative to project root (default: "emails")
712    #[serde(default = "default_email_template_dir")]
713    pub template_dir: String,
714}
715
716/// SMTP transport configuration
717#[derive(Debug, Deserialize, Clone)]
718pub struct SmtpConfig {
719    /// SMTP host
720    pub host: String,
721
722    /// SMTP port (default: 587)
723    #[serde(default = "default_smtp_port")]
724    pub port: u16,
725
726    /// SMTP username (supports `${ENV_VAR}` syntax)
727    pub username: Option<String>,
728
729    /// SMTP password (supports `${ENV_VAR}` syntax)
730    pub password: Option<String>,
731}
732
733/// API-based email provider configuration (e.g. Resend)
734#[derive(Debug, Deserialize, Clone)]
735pub struct EmailApiConfig {
736    /// Provider name: "resend"
737    pub provider: String,
738
739    /// API key (supports `${ENV_VAR}` syntax)
740    pub api_key: String,
741}
742
743fn default_email_template_dir() -> String {
744    "emails".to_string()
745}
746
747fn default_smtp_port() -> u16 {
748    587
749}
750
751/// Resolve the config file path for a project directory.
752///
753/// Prefers `what.toml`; falls back to the legacy `wwwhat.toml` name (still
754/// fully supported). When neither exists, returns the canonical `what.toml`
755/// path so callers report the current name in messages.
756pub fn resolve_config_path(project_dir: &Path) -> PathBuf {
757    let canonical = project_dir.join("what.toml");
758    if canonical.exists() {
759        return canonical;
760    }
761    let legacy = project_dir.join("wwwhat.toml");
762    if legacy.exists() {
763        return legacy;
764    }
765    canonical
766}
767
768impl Config {
769    /// Load configuration from a what.toml file.
770    /// Unknown keys are warned about instead of silently dropped — a typo
771    /// like `prt = 3000` otherwise falls back to the default port with no
772    /// symptom at all — but never fail the load (forward compatibility).
773    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
774        let file_name = path
775            .as_ref()
776            .file_name()
777            .map(|n| n.to_string_lossy().into_owned())
778            .unwrap_or_else(|| "what.toml".to_string());
779        let content = std::fs::read_to_string(path)?;
780        let de = toml::de::Deserializer::new(&content);
781        let mut unknown_keys: Vec<String> = Vec::new();
782        let config: Config = serde_ignored::deserialize(de, |ignored_path| {
783            unknown_keys.push(ignored_path.to_string());
784        })?;
785        for key in unknown_keys {
786            tracing::warn!(
787                "{}: unknown key '{}' is ignored — possible typo? The default value applies.",
788                file_name,
789                key
790            );
791        }
792        Ok(config)
793    }
794
795    /// Load configuration from the current directory
796    pub fn load_from_current_dir() -> Result<Self> {
797        let path = resolve_config_path(&std::env::current_dir()?);
798        if path.exists() {
799            Self::load(path)
800        } else {
801            Ok(Config::default())
802        }
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    #[test]
811    fn test_load_tolerates_unknown_keys() {
812        // Unknown keys warn (typo detection) but must never fail the load
813        let dir = tempfile::tempdir().unwrap();
814        let path = dir.path().join("what.toml");
815        std::fs::write(&path, "[server]\nprt = 3000\nport = 9090\n\n[nonsense]\nfoo = 1\n")
816            .unwrap();
817        let config = Config::load(&path).unwrap();
818        assert_eq!(config.server.port, 9090);
819    }
820
821    // --- Default value tests ---
822
823    #[test]
824    fn test_config_default() {
825        let config = Config::default();
826        assert_eq!(config.server.port, 8085);
827        assert_eq!(config.server.host, "127.0.0.1");
828        assert!(config.data.is_empty());
829        assert!(config.cache.enabled);
830        assert_eq!(config.cache.ttl, 300);
831        assert!(config.cache.redis_url.is_none());
832        assert!(config.session.enabled);
833        assert_eq!(config.session.cookie_name, "w_session");
834        assert_eq!(config.session.max_age, 604800);
835        assert!(config.session.secure);
836        assert_eq!(config.session.database, "sessions.db");
837        assert!(!config.auth.enabled);
838        assert!(config.auth.login_endpoint.is_none());
839        assert!(config.auth.logout_endpoint.is_none());
840        assert_eq!(config.auth.jwt_cookie_name, "w_token");
841        assert_eq!(config.auth.after_login, "/");
842        assert_eq!(config.auth.login_path, "/login");
843        assert!(config.auth.protected_paths.is_empty());
844        assert!(config.auth.jwt_secret.is_none());
845        assert_eq!(
846            config.auth.jwt_claims,
847            vec!["id", "user_id", "email", "full_name"]
848        );
849    }
850
851    #[test]
852    fn test_server_config_default() {
853        let server = ServerConfig::default();
854        assert_eq!(server.port, 8085);
855        assert_eq!(server.host, "127.0.0.1");
856    }
857
858    #[test]
859    fn test_cache_config_default() {
860        let cache = CacheConfig::default();
861        assert!(cache.enabled);
862        assert_eq!(cache.ttl, 300);
863        assert!(cache.redis_url.is_none());
864    }
865
866    #[test]
867    fn test_session_config_default() {
868        let session = SessionConfig::default();
869        assert!(session.enabled);
870        assert_eq!(session.store, "sqlite");
871        assert_eq!(session.cookie_name, "w_session");
872        assert_eq!(session.max_age, 604800); // 7 days
873        assert!(session.secure); // Secure=true by default for production safety
874        assert_eq!(session.database, "sessions.db");
875        assert!(session.cloudflare.is_none());
876    }
877
878    #[test]
879    fn test_auth_config_default() {
880        let auth = AuthConfig::default();
881        assert!(!auth.enabled);
882        assert!(auth.login_endpoint.is_none());
883        assert!(auth.logout_endpoint.is_none());
884        assert_eq!(auth.jwt_cookie_name, "w_token");
885        assert_eq!(auth.after_login, "/");
886        assert_eq!(auth.login_path, "/login");
887        assert!(auth.protected_paths.is_empty());
888        assert!(auth.jwt_secret.is_none());
889    }
890
891    // --- TOML parsing tests ---
892
893    #[test]
894    fn test_parse_empty_toml() {
895        let config: Config = toml::from_str("").unwrap();
896        // All sections should fall back to defaults
897        assert_eq!(config.server.port, 8085);
898        assert_eq!(config.server.host, "127.0.0.1");
899        assert!(config.data.is_empty());
900        assert!(config.cache.enabled);
901    }
902
903    #[test]
904    fn test_parse_full_config() {
905        let toml_str = r#"
906[server]
907port = 3000
908host = "0.0.0.0"
909
910[data.products]
911url = "https://api.example.com/products"
912cache = 600
913
914[data.posts]
915file = "data/posts.json"
916cache = 120
917
918[data]
919simple = "data/items.json"
920
921[cache]
922enabled = false
923ttl = 60
924redis_url = "redis://localhost:6379"
925
926[session]
927enabled = false
928cookie_name = "my_session"
929max_age = 3600
930secure = true
931database = "my_sessions.db"
932
933[auth]
934enabled = true
935login_endpoint = "https://api.example.com/auth/login"
936logout_endpoint = "https://api.example.com/auth/logout"
937jwt_cookie_name = "my_token"
938after_login = "/dashboard"
939login_path = "/signin"
940protected_paths = ["/admin/*", "/dashboard/*"]
941jwt_secret = "supersecret"
942jwt_claims = ["id", "email", "role"]
943"#;
944        let config: Config = toml::from_str(toml_str).unwrap();
945
946        assert_eq!(config.server.port, 3000);
947        assert_eq!(config.server.host, "0.0.0.0");
948
949        assert!(!config.cache.enabled);
950        assert_eq!(config.cache.ttl, 60);
951        assert_eq!(
952            config.cache.redis_url.as_deref(),
953            Some("redis://localhost:6379")
954        );
955
956        assert!(!config.session.enabled);
957        assert_eq!(config.session.cookie_name, "my_session");
958        assert_eq!(config.session.max_age, 3600);
959        assert!(config.session.secure);
960        assert_eq!(config.session.database, "my_sessions.db");
961
962        assert!(config.auth.enabled);
963        assert_eq!(
964            config.auth.login_endpoint.as_deref(),
965            Some("https://api.example.com/auth/login")
966        );
967        assert_eq!(
968            config.auth.logout_endpoint.as_deref(),
969            Some("https://api.example.com/auth/logout")
970        );
971        assert_eq!(config.auth.jwt_cookie_name, "my_token");
972        assert_eq!(config.auth.after_login, "/dashboard");
973        assert_eq!(config.auth.login_path, "/signin");
974        assert_eq!(
975            config.auth.protected_paths,
976            vec!["/admin/*", "/dashboard/*"]
977        );
978        assert_eq!(config.auth.jwt_secret.as_deref(), Some("supersecret"));
979        assert_eq!(config.auth.jwt_claims, vec!["id", "email", "role"]);
980    }
981
982    #[test]
983    fn test_parse_partial_config_only_server() {
984        let toml_str = r#"
985[server]
986port = 9090
987"#;
988        let config: Config = toml::from_str(toml_str).unwrap();
989
990        assert_eq!(config.server.port, 9090);
991        assert_eq!(config.server.host, "127.0.0.1"); // default
992        assert!(config.data.is_empty()); // default
993        assert!(config.cache.enabled); // default
994        assert!(config.session.enabled); // default
995        assert!(!config.auth.enabled); // default
996    }
997
998    #[test]
999    fn test_parse_partial_config_only_auth() {
1000        let toml_str = r#"
1001[auth]
1002enabled = true
1003login_endpoint = "https://api.example.com/login"
1004"#;
1005        let config: Config = toml::from_str(toml_str).unwrap();
1006
1007        assert!(config.auth.enabled);
1008        assert_eq!(
1009            config.auth.login_endpoint.as_deref(),
1010            Some("https://api.example.com/login")
1011        );
1012        // Other auth fields should be defaults
1013        assert_eq!(config.auth.jwt_cookie_name, "w_token");
1014        assert_eq!(config.auth.after_login, "/");
1015        assert_eq!(config.auth.login_path, "/login");
1016        // Other sections should be defaults
1017        assert_eq!(config.server.port, 8085);
1018    }
1019
1020    #[test]
1021    fn test_session_secure_defaults_true() {
1022        // No [session] section — secure should default to true
1023        let config: Config = toml::from_str("").unwrap();
1024        assert!(config.session.secure);
1025    }
1026
1027    #[test]
1028    fn test_session_secure_can_be_disabled() {
1029        let toml_str = r#"
1030[session]
1031secure = false
1032"#;
1033        let config: Config = toml::from_str(toml_str).unwrap();
1034        assert!(!config.session.secure);
1035    }
1036
1037    // --- Data source variant tests ---
1038
1039    #[test]
1040    fn test_data_source_url_variant() {
1041        let toml_str = r#"
1042[data.api]
1043url = "https://api.example.com/data"
1044cache = 120
1045"#;
1046        let config: Config = toml::from_str(toml_str).unwrap();
1047        let source = config.data.get("api").expect("data.api should exist");
1048
1049        match source {
1050            DataSource::Url { url, cache } => {
1051                assert_eq!(url, "https://api.example.com/data");
1052                assert_eq!(*cache, 120);
1053            }
1054            _ => panic!("Expected DataSource::Url variant"),
1055        }
1056    }
1057
1058    #[test]
1059    fn test_data_source_url_default_cache() {
1060        let toml_str = r#"
1061[data.api]
1062url = "https://api.example.com/data"
1063"#;
1064        let config: Config = toml::from_str(toml_str).unwrap();
1065        let source = config.data.get("api").unwrap();
1066
1067        match source {
1068            DataSource::Url { cache, .. } => {
1069                assert_eq!(*cache, 300); // default_cache_ttl
1070            }
1071            _ => panic!("Expected DataSource::Url variant"),
1072        }
1073    }
1074
1075    #[test]
1076    fn test_data_source_file_variant() {
1077        let toml_str = r#"
1078[data.local]
1079file = "data/products.json"
1080cache = 60
1081"#;
1082        let config: Config = toml::from_str(toml_str).unwrap();
1083        let source = config.data.get("local").unwrap();
1084
1085        match source {
1086            DataSource::File { file, cache } => {
1087                assert_eq!(file, "data/products.json");
1088                assert_eq!(*cache, 60);
1089            }
1090            _ => panic!("Expected DataSource::File variant"),
1091        }
1092    }
1093
1094    #[test]
1095    fn test_data_source_file_default_cache() {
1096        let toml_str = r#"
1097[data.local]
1098file = "data/products.json"
1099"#;
1100        let config: Config = toml::from_str(toml_str).unwrap();
1101        let source = config.data.get("local").unwrap();
1102
1103        match source {
1104            DataSource::File { cache, .. } => {
1105                assert_eq!(*cache, 300); // default_cache_ttl
1106            }
1107            _ => panic!("Expected DataSource::File variant"),
1108        }
1109    }
1110
1111    #[test]
1112    fn test_data_source_simple_path_variant() {
1113        let toml_str = r#"
1114[data]
1115items = "data/items.json"
1116"#;
1117        let config: Config = toml::from_str(toml_str).unwrap();
1118        let source = config.data.get("items").unwrap();
1119
1120        match source {
1121            DataSource::SimplePath(path) => {
1122                assert_eq!(path, "data/items.json");
1123            }
1124            _ => panic!("Expected DataSource::SimplePath variant"),
1125        }
1126    }
1127
1128    #[test]
1129    fn test_multiple_data_sources() {
1130        let toml_str = r#"
1131[data]
1132simple = "data/simple.json"
1133
1134[data.api]
1135url = "https://api.example.com"
1136
1137[data.local]
1138file = "data/local.json"
1139"#;
1140        let config: Config = toml::from_str(toml_str).unwrap();
1141        assert_eq!(config.data.len(), 3);
1142        assert!(config.data.contains_key("simple"));
1143        assert!(config.data.contains_key("api"));
1144        assert!(config.data.contains_key("local"));
1145    }
1146
1147    // --- Datasource config tests ---
1148
1149    #[test]
1150    fn test_datasources_default_empty() {
1151        let config = Config::default();
1152        assert!(config.datasources.is_empty());
1153    }
1154
1155    #[test]
1156    fn test_parse_datasources_all_types() {
1157        let toml_str = r##"
1158[datasources.users]
1159type = "supabase"
1160project_url = "https://xxx.supabase.co"
1161api_key = "sk-xxx"
1162
1163[datasources.content]
1164type = "d1"
1165account_id = "abc123"
1166api_token = "token123"
1167d1_database_id = "db-456"
1168
1169[datasources.inventory]
1170type = "api"
1171url = "https://inventory.example.com"
1172headers = { Authorization = "Bearer tok123" }
1173
1174[datasources.local_extra]
1175type = "sqlite"
1176path = "data/extra.db"
1177"##;
1178        let config: Config = toml::from_str(toml_str).unwrap();
1179        assert_eq!(config.datasources.len(), 4);
1180
1181        let users = &config.datasources["users"];
1182        assert_eq!(users.r#type, DatasourceType::Supabase);
1183        assert_eq!(
1184            users.project_url.as_deref(),
1185            Some("https://xxx.supabase.co")
1186        );
1187        assert_eq!(users.api_key.as_deref(), Some("sk-xxx"));
1188
1189        let content = &config.datasources["content"];
1190        assert_eq!(content.r#type, DatasourceType::D1);
1191        assert_eq!(content.account_id.as_deref(), Some("abc123"));
1192        assert_eq!(content.d1_database_id.as_deref(), Some("db-456"));
1193
1194        let inventory = &config.datasources["inventory"];
1195        assert_eq!(inventory.r#type, DatasourceType::Api);
1196        assert_eq!(
1197            inventory.url.as_deref(),
1198            Some("https://inventory.example.com")
1199        );
1200        let headers = inventory.headers.as_ref().unwrap();
1201        assert_eq!(headers["Authorization"], "Bearer tok123");
1202
1203        let local = &config.datasources["local_extra"];
1204        assert_eq!(local.r#type, DatasourceType::Sqlite);
1205        assert_eq!(local.path.as_deref(), Some("data/extra.db"));
1206    }
1207
1208    // --- File loading tests ---
1209
1210    #[test]
1211    fn test_load_nonexistent_file() {
1212        let result = Config::load("/nonexistent/path/what.toml");
1213        assert!(result.is_err());
1214    }
1215
1216    #[test]
1217    fn test_invalid_toml_parsing() {
1218        let bad_toml = "this is not [valid toml ===";
1219        let result: std::result::Result<Config, _> = toml::from_str(bad_toml);
1220        assert!(result.is_err());
1221    }
1222
1223    // --- Clone and Debug trait tests ---
1224
1225    #[test]
1226    fn test_config_is_cloneable() {
1227        let config = Config::default();
1228        let cloned = config.clone();
1229        assert_eq!(cloned.server.port, config.server.port);
1230        assert_eq!(cloned.server.host, config.server.host);
1231    }
1232
1233    #[test]
1234    fn test_config_is_debuggable() {
1235        let config = Config::default();
1236        let debug_str = format!("{:?}", config);
1237        assert!(debug_str.contains("Config"));
1238        assert!(debug_str.contains("8085"));
1239    }
1240
1241    #[test]
1242    fn test_invalid_datasource_type_rejected() {
1243        let toml_str = r#"
1244[datasources.bad]
1245type = "mongodb"
1246url = "https://example.com"
1247"#;
1248        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
1249        assert!(
1250            result.is_err(),
1251            "Unknown datasource type should fail deserialization"
1252        );
1253    }
1254
1255    #[test]
1256    fn test_datasource_type_case_sensitive() {
1257        let toml_str = r#"
1258[datasources.bad]
1259type = "Supabase"
1260project_url = "https://xxx.supabase.co"
1261api_key = "key"
1262"#;
1263        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
1264        assert!(result.is_err(), "Datasource type must be lowercase");
1265    }
1266
1267    #[test]
1268    fn test_datasource_missing_type_field() {
1269        let toml_str = r#"
1270[datasources.notype]
1271url = "https://example.com"
1272"#;
1273        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
1274        assert!(result.is_err(), "Datasource without type field should fail");
1275    }
1276}