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    /// SSRF guard: when false (default), `fetch` directives and API datasources
279    /// may not reach loopback/private/link-local/metadata addresses. Set true to
280    /// allow fetching internal services (self-hosted apps hitting a co-located
281    /// API). Per-host exceptions go in `fetch_allowed_hosts`.
282    #[serde(default)]
283    pub allow_private_fetch: bool,
284
285    /// SSRF guard: hostnames (or IP literals) exempt from the private-address
286    /// block even when `allow_private_fetch` is false. Exact, case-insensitive.
287    #[serde(default)]
288    pub fetch_allowed_hosts: Vec<String>,
289
290    /// Number of trusted reverse proxies in front of the app, for rate-limit
291    /// client-IP detection. 0 (default) = trust only the socket peer and IGNORE
292    /// `X-Forwarded-For` (so it can't be spoofed to dodge limits). Set to 1 when
293    /// behind a single reverse proxy (e.g. nginx) so the real client IP is read
294    /// from the proxy-appended XFF entry. Assumes the app is reachable ONLY
295    /// through the proxy.
296    #[serde(default)]
297    pub trusted_proxy_hops: usize,
298}
299
300impl Default for ServerConfig {
301    fn default() -> Self {
302        Self {
303            port: default_port(),
304            host: default_host(),
305            max_body_size: default_max_body_size(),
306            fetch_timeout: default_fetch_timeout(),
307            source_viewer: false,
308            css: default_css_mode(),
309            allow_private_fetch: false,
310            fetch_allowed_hosts: Vec::new(),
311            trusted_proxy_hops: 0,
312        }
313    }
314}
315
316fn default_css_mode() -> String {
317    "full".to_string()
318}
319
320fn default_fetch_timeout() -> u64 {
321    10
322}
323
324fn default_max_body_size() -> String {
325    "10mb".to_string()
326}
327
328fn default_port() -> u16 {
329    8085
330}
331
332fn default_host() -> String {
333    "127.0.0.1".to_string()
334}
335
336/// Data source definition for the central data store
337#[derive(Debug, Deserialize, Clone)]
338#[serde(untagged)]
339pub enum DataSource {
340    /// URL-based data source (external API)
341    Url {
342        url: String,
343        #[serde(default = "default_cache_ttl")]
344        cache: u64,
345    },
346    /// File-based data source (local JSON file)
347    File {
348        file: String,
349        #[serde(default = "default_cache_ttl")]
350        cache: u64,
351    },
352    /// Simple string path (shorthand for file)
353    SimplePath(String),
354}
355
356fn default_cache_ttl() -> u64 {
357    300 // 5 minutes
358}
359
360/// Cache configuration
361#[derive(Debug, Deserialize, Clone)]
362pub struct CacheConfig {
363    /// Whether caching is enabled
364    #[serde(default = "default_cache_enabled")]
365    pub enabled: bool,
366
367    /// Default TTL in seconds
368    #[serde(default = "default_cache_ttl")]
369    pub ttl: u64,
370
371    /// Redis URL (optional, uses memory cache if not set)
372    pub redis_url: Option<String>,
373}
374
375impl Default for CacheConfig {
376    fn default() -> Self {
377        Self {
378            enabled: default_cache_enabled(),
379            ttl: default_cache_ttl(),
380            redis_url: None,
381        }
382    }
383}
384
385fn default_cache_enabled() -> bool {
386    true
387}
388
389/// Session configuration
390#[derive(Debug, Deserialize, Clone)]
391pub struct SessionConfig {
392    /// Whether sessions are enabled
393    #[serde(default = "default_session_enabled")]
394    pub enabled: bool,
395
396    /// Storage backend: "sqlite" (default) or "cloudflare-kv"
397    #[serde(default = "default_session_store")]
398    pub store: String,
399
400    /// Cookie name for the session ID
401    #[serde(default = "default_cookie_name")]
402    pub cookie_name: String,
403
404    /// Session max age in seconds (default: 7 days)
405    #[serde(default = "default_session_max_age")]
406    pub max_age: i64,
407
408    /// Whether to use Secure flag on cookie (defaults to true for production safety)
409    #[serde(default = "default_session_secure")]
410    pub secure: bool,
411
412    /// SQLite database file for sessions (used when store = "sqlite")
413    #[serde(default = "default_session_database")]
414    pub database: String,
415
416    /// Cloudflare KV configuration (used when store = "cloudflare-kv")
417    #[serde(default)]
418    pub cloudflare: Option<CloudflareKvConfig>,
419}
420
421/// Cloudflare Workers KV configuration
422#[derive(Debug, Deserialize, Clone)]
423pub struct CloudflareKvConfig {
424    /// Cloudflare account ID
425    pub account_id: String,
426    /// KV namespace ID
427    pub namespace_id: String,
428    /// API token with KV read/write permissions
429    pub api_token: String,
430}
431
432impl Default for SessionConfig {
433    fn default() -> Self {
434        Self {
435            enabled: default_session_enabled(),
436            store: default_session_store(),
437            cookie_name: default_cookie_name(),
438            max_age: default_session_max_age(),
439            secure: default_session_secure(),
440            database: default_session_database(),
441            cloudflare: None,
442        }
443    }
444}
445
446fn default_session_enabled() -> bool {
447    true
448}
449
450fn default_session_store() -> String {
451    "sqlite".to_string()
452}
453
454fn default_cookie_name() -> String {
455    "w_session".to_string()
456}
457
458fn default_session_max_age() -> i64 {
459    604800 // 7 days in seconds
460}
461
462fn default_session_secure() -> bool {
463    true
464}
465
466fn default_session_database() -> String {
467    "sessions.db".to_string()
468}
469
470/// Authentication configuration
471#[derive(Debug, Deserialize, Clone)]
472pub struct AuthConfig {
473    /// Whether authentication is enabled
474    #[serde(default)]
475    pub enabled: bool,
476
477    /// Backend API URL for authentication (e.g., "https://api.example.com/auth/login")
478    pub login_endpoint: Option<String>,
479
480    /// Backend API URL for logout (optional)
481    pub logout_endpoint: Option<String>,
482
483    /// Cookie name for the JWT token
484    #[serde(default = "default_jwt_cookie_name")]
485    pub jwt_cookie_name: String,
486
487    /// Path to redirect to after login
488    #[serde(default = "default_after_login")]
489    pub after_login: String,
490
491    /// Path to the login page
492    #[serde(default = "default_login_path")]
493    pub login_path: String,
494
495    /// Paths that require authentication (glob patterns)
496    #[serde(default)]
497    pub protected_paths: Vec<String>,
498
499    /// JWT secret for validation (optional - if not set, JWT is decoded but not verified)
500    pub jwt_secret: Option<String>,
501
502    /// JWT claims to extract and make available in templates
503    #[serde(default = "default_jwt_claims")]
504    pub jwt_claims: Vec<String>,
505}
506
507impl Default for AuthConfig {
508    fn default() -> Self {
509        Self {
510            enabled: false,
511            login_endpoint: None,
512            logout_endpoint: None,
513            jwt_cookie_name: default_jwt_cookie_name(),
514            after_login: default_after_login(),
515            login_path: default_login_path(),
516            protected_paths: vec![],
517            jwt_secret: None,
518            jwt_claims: default_jwt_claims(),
519        }
520    }
521}
522
523fn default_jwt_cookie_name() -> String {
524    "w_token".to_string()
525}
526
527fn default_after_login() -> String {
528    "/".to_string()
529}
530
531fn default_login_path() -> String {
532    "/login".to_string()
533}
534
535fn default_jwt_claims() -> Vec<String> {
536    vec![
537        "id".to_string(),
538        "user_id".to_string(),
539        "email".to_string(),
540        "full_name".to_string(),
541    ]
542}
543
544/// Upload configuration
545#[derive(Debug, Deserialize, Clone)]
546pub struct UploadConfig {
547    /// Whether file uploads are enabled
548    #[serde(default)]
549    pub enabled: bool,
550
551    /// Storage provider: "local" (default) or "r2"
552    #[serde(default = "default_upload_provider")]
553    pub provider: String,
554
555    /// Directory for uploaded files (relative to project root, local only)
556    #[serde(default = "default_upload_directory")]
557    pub directory: String,
558
559    /// Maximum file size (e.g., "10mb", "500kb", "1gb")
560    #[serde(default = "default_upload_max_size")]
561    pub max_size: String,
562
563    /// Allowed MIME types (supports wildcards like "image/*")
564    #[serde(default)]
565    pub allowed_types: Vec<String>,
566}
567
568impl Default for UploadConfig {
569    fn default() -> Self {
570        Self {
571            enabled: false,
572            provider: default_upload_provider(),
573            directory: default_upload_directory(),
574            max_size: default_upload_max_size(),
575            allowed_types: vec![],
576        }
577    }
578}
579
580fn default_upload_provider() -> String {
581    "local".to_string()
582}
583
584fn default_upload_directory() -> String {
585    "uploads".to_string()
586}
587
588fn default_upload_max_size() -> String {
589    "10mb".to_string()
590}
591
592impl UploadConfig {
593    /// Parse the max_size string ("10mb", "500kb", "1gb") into bytes
594    pub fn max_size_bytes(&self) -> usize {
595        parse_size_string(&self.max_size)
596    }
597
598    /// Check if a MIME type is allowed by the configuration
599    pub fn is_type_allowed(&self, content_type: &str) -> bool {
600        if self.allowed_types.is_empty() {
601            return true; // No restrictions = allow all
602        }
603        for allowed in &self.allowed_types {
604            if allowed == content_type {
605                return true;
606            }
607            // Wildcard match: "image/*" matches "image/png"
608            if allowed.ends_with("/*") {
609                let prefix = &allowed[..allowed.len() - 1];
610                if content_type.starts_with(prefix) {
611                    return true;
612                }
613            }
614            // Extension match: ".pdf" matches common MIME types
615            if allowed.starts_with('.') {
616                if mime_matches_extension(content_type, allowed) {
617                    return true;
618                }
619            }
620        }
621        false
622    }
623}
624
625/// Parse human-readable size strings into bytes
626pub fn parse_size_string(s: &str) -> usize {
627    let s = s.trim().to_lowercase();
628    if let Some(num) = s.strip_suffix("gb") {
629        num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024
630    } else if let Some(num) = s.strip_suffix("mb") {
631        num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024
632    } else if let Some(num) = s.strip_suffix("kb") {
633        num.trim().parse::<usize>().unwrap_or(0) * 1024
634    } else {
635        s.parse::<usize>().unwrap_or(10 * 1024 * 1024) // default 10MB
636    }
637}
638
639fn mime_matches_extension(content_type: &str, extension: &str) -> bool {
640    match extension {
641        ".pdf" => content_type == "application/pdf",
642        ".doc" => content_type == "application/msword",
643        ".docx" => {
644            content_type
645                == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
646        }
647        ".xls" => content_type == "application/vnd.ms-excel",
648        ".xlsx" => {
649            content_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
650        }
651        ".csv" => content_type == "text/csv",
652        ".txt" => content_type == "text/plain",
653        ".zip" => content_type == "application/zip",
654        _ => false,
655    }
656}
657
658/// Rate limiting configuration
659#[derive(Debug, Deserialize, Clone)]
660pub struct RateLimitConfig {
661    /// Whether rate limiting is enabled
662    #[serde(default = "default_rate_limit_enabled")]
663    pub enabled: bool,
664
665    /// Login endpoint limit: "requests/seconds" (e.g., "5/60" = 5 per 60s)
666    #[serde(default = "default_rate_limit_login")]
667    pub login: String,
668
669    /// Upload endpoint limit: "requests/seconds"
670    #[serde(default = "default_rate_limit_upload")]
671    pub upload: String,
672
673    /// Action endpoint limit: "requests/seconds"
674    #[serde(default = "default_rate_limit_action")]
675    pub action: String,
676}
677
678impl Default for RateLimitConfig {
679    fn default() -> Self {
680        Self {
681            enabled: default_rate_limit_enabled(),
682            login: default_rate_limit_login(),
683            upload: default_rate_limit_upload(),
684            action: default_rate_limit_action(),
685        }
686    }
687}
688
689fn default_rate_limit_enabled() -> bool {
690    true
691}
692
693fn default_rate_limit_login() -> String {
694    "5/60".to_string()
695}
696
697fn default_rate_limit_upload() -> String {
698    "10/60".to_string()
699}
700
701fn default_rate_limit_action() -> String {
702    "30/60".to_string()
703}
704
705impl RateLimitConfig {
706    /// Parse a rate limit string like "5/60" into (max_requests, window_seconds)
707    pub fn parse_limit(s: &str) -> (u32, u64) {
708        let parts: Vec<&str> = s.split('/').collect();
709        if parts.len() == 2 {
710            let max = parts[0].trim().parse().unwrap_or(5);
711            let window = parts[1].trim().parse().unwrap_or(60);
712            (max, window)
713        } else {
714            (5, 60)
715        }
716    }
717}
718
719/// Email configuration
720#[derive(Debug, Deserialize, Clone)]
721pub struct EmailConfig {
722    /// Sender email address
723    pub from: String,
724
725    /// Sender display name (optional)
726    #[serde(default)]
727    pub from_name: Option<String>,
728
729    /// SMTP transport settings (mutually exclusive with `api`)
730    pub smtp: Option<SmtpConfig>,
731
732    /// API transport settings — e.g. Resend (mutually exclusive with `smtp`)
733    pub api: Option<EmailApiConfig>,
734
735    /// Template directory relative to project root (default: "emails")
736    #[serde(default = "default_email_template_dir")]
737    pub template_dir: String,
738}
739
740/// SMTP transport configuration
741#[derive(Debug, Deserialize, Clone)]
742pub struct SmtpConfig {
743    /// SMTP host
744    pub host: String,
745
746    /// SMTP port (default: 587)
747    #[serde(default = "default_smtp_port")]
748    pub port: u16,
749
750    /// SMTP username (supports `${ENV_VAR}` syntax)
751    pub username: Option<String>,
752
753    /// SMTP password (supports `${ENV_VAR}` syntax)
754    pub password: Option<String>,
755}
756
757/// API-based email provider configuration (e.g. Resend)
758#[derive(Debug, Deserialize, Clone)]
759pub struct EmailApiConfig {
760    /// Provider name: "resend"
761    pub provider: String,
762
763    /// API key (supports `${ENV_VAR}` syntax)
764    pub api_key: String,
765}
766
767fn default_email_template_dir() -> String {
768    "emails".to_string()
769}
770
771fn default_smtp_port() -> u16 {
772    587
773}
774
775/// Resolve the config file path for a project directory.
776///
777/// Prefers `what.toml`; falls back to the legacy `wwwhat.toml` name (still
778/// fully supported). When neither exists, returns the canonical `what.toml`
779/// path so callers report the current name in messages.
780pub fn resolve_config_path(project_dir: &Path) -> PathBuf {
781    let canonical = project_dir.join("what.toml");
782    if canonical.exists() {
783        return canonical;
784    }
785    let legacy = project_dir.join("wwwhat.toml");
786    if legacy.exists() {
787        return legacy;
788    }
789    canonical
790}
791
792impl Config {
793    /// Load configuration from a what.toml file.
794    /// Unknown keys are warned about instead of silently dropped — a typo
795    /// like `prt = 3000` otherwise falls back to the default port with no
796    /// symptom at all — but never fail the load (forward compatibility).
797    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
798        let file_name = path
799            .as_ref()
800            .file_name()
801            .map(|n| n.to_string_lossy().into_owned())
802            .unwrap_or_else(|| "what.toml".to_string());
803        let content = std::fs::read_to_string(path)?;
804        let de = toml::de::Deserializer::new(&content);
805        let mut unknown_keys: Vec<String> = Vec::new();
806        let config: Config = serde_ignored::deserialize(de, |ignored_path| {
807            unknown_keys.push(ignored_path.to_string());
808        })?;
809        for key in unknown_keys {
810            tracing::warn!(
811                "{}: unknown key '{}' is ignored — possible typo? The default value applies.",
812                file_name,
813                key
814            );
815        }
816        Ok(config)
817    }
818
819    /// Load configuration from the current directory
820    pub fn load_from_current_dir() -> Result<Self> {
821        let path = resolve_config_path(&std::env::current_dir()?);
822        if path.exists() {
823            Self::load(path)
824        } else {
825            Ok(Config::default())
826        }
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn test_load_tolerates_unknown_keys() {
836        // Unknown keys warn (typo detection) but must never fail the load
837        let dir = tempfile::tempdir().unwrap();
838        let path = dir.path().join("what.toml");
839        std::fs::write(&path, "[server]\nprt = 3000\nport = 9090\n\n[nonsense]\nfoo = 1\n")
840            .unwrap();
841        let config = Config::load(&path).unwrap();
842        assert_eq!(config.server.port, 9090);
843    }
844
845    // --- Default value tests ---
846
847    #[test]
848    fn test_config_default() {
849        let config = Config::default();
850        assert_eq!(config.server.port, 8085);
851        assert_eq!(config.server.host, "127.0.0.1");
852        assert!(config.data.is_empty());
853        assert!(config.cache.enabled);
854        assert_eq!(config.cache.ttl, 300);
855        assert!(config.cache.redis_url.is_none());
856        assert!(config.session.enabled);
857        assert_eq!(config.session.cookie_name, "w_session");
858        assert_eq!(config.session.max_age, 604800);
859        assert!(config.session.secure);
860        assert_eq!(config.session.database, "sessions.db");
861        assert!(!config.auth.enabled);
862        assert!(config.auth.login_endpoint.is_none());
863        assert!(config.auth.logout_endpoint.is_none());
864        assert_eq!(config.auth.jwt_cookie_name, "w_token");
865        assert_eq!(config.auth.after_login, "/");
866        assert_eq!(config.auth.login_path, "/login");
867        assert!(config.auth.protected_paths.is_empty());
868        assert!(config.auth.jwt_secret.is_none());
869        assert_eq!(
870            config.auth.jwt_claims,
871            vec!["id", "user_id", "email", "full_name"]
872        );
873    }
874
875    #[test]
876    fn test_server_config_default() {
877        let server = ServerConfig::default();
878        assert_eq!(server.port, 8085);
879        assert_eq!(server.host, "127.0.0.1");
880    }
881
882    #[test]
883    fn test_cache_config_default() {
884        let cache = CacheConfig::default();
885        assert!(cache.enabled);
886        assert_eq!(cache.ttl, 300);
887        assert!(cache.redis_url.is_none());
888    }
889
890    #[test]
891    fn test_session_config_default() {
892        let session = SessionConfig::default();
893        assert!(session.enabled);
894        assert_eq!(session.store, "sqlite");
895        assert_eq!(session.cookie_name, "w_session");
896        assert_eq!(session.max_age, 604800); // 7 days
897        assert!(session.secure); // Secure=true by default for production safety
898        assert_eq!(session.database, "sessions.db");
899        assert!(session.cloudflare.is_none());
900    }
901
902    #[test]
903    fn test_auth_config_default() {
904        let auth = AuthConfig::default();
905        assert!(!auth.enabled);
906        assert!(auth.login_endpoint.is_none());
907        assert!(auth.logout_endpoint.is_none());
908        assert_eq!(auth.jwt_cookie_name, "w_token");
909        assert_eq!(auth.after_login, "/");
910        assert_eq!(auth.login_path, "/login");
911        assert!(auth.protected_paths.is_empty());
912        assert!(auth.jwt_secret.is_none());
913    }
914
915    // --- TOML parsing tests ---
916
917    #[test]
918    fn test_parse_empty_toml() {
919        let config: Config = toml::from_str("").unwrap();
920        // All sections should fall back to defaults
921        assert_eq!(config.server.port, 8085);
922        assert_eq!(config.server.host, "127.0.0.1");
923        assert!(config.data.is_empty());
924        assert!(config.cache.enabled);
925    }
926
927    #[test]
928    fn test_parse_full_config() {
929        let toml_str = r#"
930[server]
931port = 3000
932host = "0.0.0.0"
933
934[data.products]
935url = "https://api.example.com/products"
936cache = 600
937
938[data.posts]
939file = "data/posts.json"
940cache = 120
941
942[data]
943simple = "data/items.json"
944
945[cache]
946enabled = false
947ttl = 60
948redis_url = "redis://localhost:6379"
949
950[session]
951enabled = false
952cookie_name = "my_session"
953max_age = 3600
954secure = true
955database = "my_sessions.db"
956
957[auth]
958enabled = true
959login_endpoint = "https://api.example.com/auth/login"
960logout_endpoint = "https://api.example.com/auth/logout"
961jwt_cookie_name = "my_token"
962after_login = "/dashboard"
963login_path = "/signin"
964protected_paths = ["/admin/*", "/dashboard/*"]
965jwt_secret = "supersecret"
966jwt_claims = ["id", "email", "role"]
967"#;
968        let config: Config = toml::from_str(toml_str).unwrap();
969
970        assert_eq!(config.server.port, 3000);
971        assert_eq!(config.server.host, "0.0.0.0");
972
973        assert!(!config.cache.enabled);
974        assert_eq!(config.cache.ttl, 60);
975        assert_eq!(
976            config.cache.redis_url.as_deref(),
977            Some("redis://localhost:6379")
978        );
979
980        assert!(!config.session.enabled);
981        assert_eq!(config.session.cookie_name, "my_session");
982        assert_eq!(config.session.max_age, 3600);
983        assert!(config.session.secure);
984        assert_eq!(config.session.database, "my_sessions.db");
985
986        assert!(config.auth.enabled);
987        assert_eq!(
988            config.auth.login_endpoint.as_deref(),
989            Some("https://api.example.com/auth/login")
990        );
991        assert_eq!(
992            config.auth.logout_endpoint.as_deref(),
993            Some("https://api.example.com/auth/logout")
994        );
995        assert_eq!(config.auth.jwt_cookie_name, "my_token");
996        assert_eq!(config.auth.after_login, "/dashboard");
997        assert_eq!(config.auth.login_path, "/signin");
998        assert_eq!(
999            config.auth.protected_paths,
1000            vec!["/admin/*", "/dashboard/*"]
1001        );
1002        assert_eq!(config.auth.jwt_secret.as_deref(), Some("supersecret"));
1003        assert_eq!(config.auth.jwt_claims, vec!["id", "email", "role"]);
1004    }
1005
1006    #[test]
1007    fn test_parse_partial_config_only_server() {
1008        let toml_str = r#"
1009[server]
1010port = 9090
1011"#;
1012        let config: Config = toml::from_str(toml_str).unwrap();
1013
1014        assert_eq!(config.server.port, 9090);
1015        assert_eq!(config.server.host, "127.0.0.1"); // default
1016        assert!(config.data.is_empty()); // default
1017        assert!(config.cache.enabled); // default
1018        assert!(config.session.enabled); // default
1019        assert!(!config.auth.enabled); // default
1020    }
1021
1022    #[test]
1023    fn test_parse_partial_config_only_auth() {
1024        let toml_str = r#"
1025[auth]
1026enabled = true
1027login_endpoint = "https://api.example.com/login"
1028"#;
1029        let config: Config = toml::from_str(toml_str).unwrap();
1030
1031        assert!(config.auth.enabled);
1032        assert_eq!(
1033            config.auth.login_endpoint.as_deref(),
1034            Some("https://api.example.com/login")
1035        );
1036        // Other auth fields should be defaults
1037        assert_eq!(config.auth.jwt_cookie_name, "w_token");
1038        assert_eq!(config.auth.after_login, "/");
1039        assert_eq!(config.auth.login_path, "/login");
1040        // Other sections should be defaults
1041        assert_eq!(config.server.port, 8085);
1042    }
1043
1044    #[test]
1045    fn test_session_secure_defaults_true() {
1046        // No [session] section — secure should default to true
1047        let config: Config = toml::from_str("").unwrap();
1048        assert!(config.session.secure);
1049    }
1050
1051    #[test]
1052    fn test_session_secure_can_be_disabled() {
1053        let toml_str = r#"
1054[session]
1055secure = false
1056"#;
1057        let config: Config = toml::from_str(toml_str).unwrap();
1058        assert!(!config.session.secure);
1059    }
1060
1061    // --- Data source variant tests ---
1062
1063    #[test]
1064    fn test_data_source_url_variant() {
1065        let toml_str = r#"
1066[data.api]
1067url = "https://api.example.com/data"
1068cache = 120
1069"#;
1070        let config: Config = toml::from_str(toml_str).unwrap();
1071        let source = config.data.get("api").expect("data.api should exist");
1072
1073        match source {
1074            DataSource::Url { url, cache } => {
1075                assert_eq!(url, "https://api.example.com/data");
1076                assert_eq!(*cache, 120);
1077            }
1078            _ => panic!("Expected DataSource::Url variant"),
1079        }
1080    }
1081
1082    #[test]
1083    fn test_data_source_url_default_cache() {
1084        let toml_str = r#"
1085[data.api]
1086url = "https://api.example.com/data"
1087"#;
1088        let config: Config = toml::from_str(toml_str).unwrap();
1089        let source = config.data.get("api").unwrap();
1090
1091        match source {
1092            DataSource::Url { cache, .. } => {
1093                assert_eq!(*cache, 300); // default_cache_ttl
1094            }
1095            _ => panic!("Expected DataSource::Url variant"),
1096        }
1097    }
1098
1099    #[test]
1100    fn test_data_source_file_variant() {
1101        let toml_str = r#"
1102[data.local]
1103file = "data/products.json"
1104cache = 60
1105"#;
1106        let config: Config = toml::from_str(toml_str).unwrap();
1107        let source = config.data.get("local").unwrap();
1108
1109        match source {
1110            DataSource::File { file, cache } => {
1111                assert_eq!(file, "data/products.json");
1112                assert_eq!(*cache, 60);
1113            }
1114            _ => panic!("Expected DataSource::File variant"),
1115        }
1116    }
1117
1118    #[test]
1119    fn test_data_source_file_default_cache() {
1120        let toml_str = r#"
1121[data.local]
1122file = "data/products.json"
1123"#;
1124        let config: Config = toml::from_str(toml_str).unwrap();
1125        let source = config.data.get("local").unwrap();
1126
1127        match source {
1128            DataSource::File { cache, .. } => {
1129                assert_eq!(*cache, 300); // default_cache_ttl
1130            }
1131            _ => panic!("Expected DataSource::File variant"),
1132        }
1133    }
1134
1135    #[test]
1136    fn test_data_source_simple_path_variant() {
1137        let toml_str = r#"
1138[data]
1139items = "data/items.json"
1140"#;
1141        let config: Config = toml::from_str(toml_str).unwrap();
1142        let source = config.data.get("items").unwrap();
1143
1144        match source {
1145            DataSource::SimplePath(path) => {
1146                assert_eq!(path, "data/items.json");
1147            }
1148            _ => panic!("Expected DataSource::SimplePath variant"),
1149        }
1150    }
1151
1152    #[test]
1153    fn test_multiple_data_sources() {
1154        let toml_str = r#"
1155[data]
1156simple = "data/simple.json"
1157
1158[data.api]
1159url = "https://api.example.com"
1160
1161[data.local]
1162file = "data/local.json"
1163"#;
1164        let config: Config = toml::from_str(toml_str).unwrap();
1165        assert_eq!(config.data.len(), 3);
1166        assert!(config.data.contains_key("simple"));
1167        assert!(config.data.contains_key("api"));
1168        assert!(config.data.contains_key("local"));
1169    }
1170
1171    // --- Datasource config tests ---
1172
1173    #[test]
1174    fn test_datasources_default_empty() {
1175        let config = Config::default();
1176        assert!(config.datasources.is_empty());
1177    }
1178
1179    #[test]
1180    fn test_parse_datasources_all_types() {
1181        let toml_str = r##"
1182[datasources.users]
1183type = "supabase"
1184project_url = "https://xxx.supabase.co"
1185api_key = "sk-xxx"
1186
1187[datasources.content]
1188type = "d1"
1189account_id = "abc123"
1190api_token = "token123"
1191d1_database_id = "db-456"
1192
1193[datasources.inventory]
1194type = "api"
1195url = "https://inventory.example.com"
1196headers = { Authorization = "Bearer tok123" }
1197
1198[datasources.local_extra]
1199type = "sqlite"
1200path = "data/extra.db"
1201"##;
1202        let config: Config = toml::from_str(toml_str).unwrap();
1203        assert_eq!(config.datasources.len(), 4);
1204
1205        let users = &config.datasources["users"];
1206        assert_eq!(users.r#type, DatasourceType::Supabase);
1207        assert_eq!(
1208            users.project_url.as_deref(),
1209            Some("https://xxx.supabase.co")
1210        );
1211        assert_eq!(users.api_key.as_deref(), Some("sk-xxx"));
1212
1213        let content = &config.datasources["content"];
1214        assert_eq!(content.r#type, DatasourceType::D1);
1215        assert_eq!(content.account_id.as_deref(), Some("abc123"));
1216        assert_eq!(content.d1_database_id.as_deref(), Some("db-456"));
1217
1218        let inventory = &config.datasources["inventory"];
1219        assert_eq!(inventory.r#type, DatasourceType::Api);
1220        assert_eq!(
1221            inventory.url.as_deref(),
1222            Some("https://inventory.example.com")
1223        );
1224        let headers = inventory.headers.as_ref().unwrap();
1225        assert_eq!(headers["Authorization"], "Bearer tok123");
1226
1227        let local = &config.datasources["local_extra"];
1228        assert_eq!(local.r#type, DatasourceType::Sqlite);
1229        assert_eq!(local.path.as_deref(), Some("data/extra.db"));
1230    }
1231
1232    // --- File loading tests ---
1233
1234    #[test]
1235    fn test_load_nonexistent_file() {
1236        let result = Config::load("/nonexistent/path/what.toml");
1237        assert!(result.is_err());
1238    }
1239
1240    #[test]
1241    fn test_invalid_toml_parsing() {
1242        let bad_toml = "this is not [valid toml ===";
1243        let result: std::result::Result<Config, _> = toml::from_str(bad_toml);
1244        assert!(result.is_err());
1245    }
1246
1247    // --- Clone and Debug trait tests ---
1248
1249    #[test]
1250    fn test_config_is_cloneable() {
1251        let config = Config::default();
1252        let cloned = config.clone();
1253        assert_eq!(cloned.server.port, config.server.port);
1254        assert_eq!(cloned.server.host, config.server.host);
1255    }
1256
1257    #[test]
1258    fn test_config_is_debuggable() {
1259        let config = Config::default();
1260        let debug_str = format!("{:?}", config);
1261        assert!(debug_str.contains("Config"));
1262        assert!(debug_str.contains("8085"));
1263    }
1264
1265    #[test]
1266    fn test_invalid_datasource_type_rejected() {
1267        let toml_str = r#"
1268[datasources.bad]
1269type = "mongodb"
1270url = "https://example.com"
1271"#;
1272        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
1273        assert!(
1274            result.is_err(),
1275            "Unknown datasource type should fail deserialization"
1276        );
1277    }
1278
1279    #[test]
1280    fn test_datasource_type_case_sensitive() {
1281        let toml_str = r#"
1282[datasources.bad]
1283type = "Supabase"
1284project_url = "https://xxx.supabase.co"
1285api_key = "key"
1286"#;
1287        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
1288        assert!(result.is_err(), "Datasource type must be lowercase");
1289    }
1290
1291    #[test]
1292    fn test_datasource_missing_type_field() {
1293        let toml_str = r#"
1294[datasources.notype]
1295url = "https://example.com"
1296"#;
1297        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
1298        assert!(result.is_err(), "Datasource without type field should fail");
1299    }
1300}