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