1use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::Result;
11
12#[derive(Debug, Deserialize, Default, Clone)]
14pub struct Config {
15 #[serde(default)]
17 pub server: ServerConfig,
18
19 #[serde(default)]
21 pub data: HashMap<String, DataSource>,
22
23 #[serde(default)]
25 pub cache: CacheConfig,
26
27 #[serde(default)]
29 pub session: SessionConfig,
30
31 #[serde(default)]
33 pub auth: AuthConfig,
34
35 #[serde(default)]
37 pub uploads: UploadConfig,
38
39 #[serde(default)]
41 pub database: Option<DatabaseConfig>,
42
43 #[serde(default)]
45 pub rate_limit: RateLimitConfig,
46
47 #[serde(default)]
49 pub email: Option<EmailConfig>,
50
51 #[serde(default)]
54 pub redirects: HashMap<String, String>,
55
56 #[serde(default)]
58 pub strict: bool,
59
60 #[serde(default)]
62 pub cloudflare: Option<CloudflareConfig>,
63
64 #[serde(default)]
66 pub supabase: Option<SupabaseConfig>,
67
68 #[serde(default)]
70 pub datasources: HashMap<String, DatasourceConfig>,
71
72 #[serde(default)]
76 pub collections: HashMap<String, CollectionPolicyConfig>,
77}
78
79#[derive(Debug, Deserialize, Clone, Default)]
96pub struct CollectionPolicyConfig {
97 pub owner: Option<String>,
99
100 pub create: Option<String>,
102
103 pub update: Option<String>,
105
106 pub delete: Option<String>,
108
109 pub read: Option<String>,
111
112 pub filter: Option<String>,
115
116 #[serde(default)]
118 pub fields: FieldRulesConfig,
119}
120
121#[derive(Debug, Deserialize, Clone, Default)]
123pub struct FieldRulesConfig {
124 #[serde(default)]
126 pub readonly: Vec<String>,
127
128 #[serde(default)]
130 pub private: Vec<String>,
131}
132
133#[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 pub r#type: DatasourceType,
167
168 pub url: Option<String>,
170
171 #[serde(default)]
173 pub headers: Option<HashMap<String, String>>,
174
175 pub account_id: Option<String>,
177
178 pub api_token: Option<String>,
180
181 pub d1_database_id: Option<String>,
183
184 pub project_url: Option<String>,
186
187 pub api_key: Option<String>,
189
190 pub path: Option<String>,
192}
193
194#[derive(Debug, Deserialize, Clone)]
196pub struct CloudflareConfig {
197 pub account_id: String,
199
200 pub api_token: String,
202
203 pub d1_database_id: Option<String>,
205
206 pub r2_bucket: Option<String>,
208
209 pub r2_public_url: Option<String>,
211
212 pub turnstile_site_key: Option<String>,
214
215 pub turnstile_secret_key: Option<String>,
217}
218
219#[derive(Debug, Deserialize, Clone)]
221pub struct SupabaseConfig {
222 pub project_url: String,
224
225 pub api_key: String,
227}
228
229#[derive(Debug, Deserialize, Clone)]
231pub struct DatabaseConfig {
232 #[serde(default = "default_db_type")]
234 pub r#type: String,
235
236 #[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#[derive(Debug, Deserialize, Clone)]
251pub struct ServerConfig {
252 #[serde(default = "default_port")]
254 pub port: u16,
255
256 #[serde(default = "default_host")]
258 pub host: String,
259
260 #[serde(default = "default_max_body_size")]
262 pub max_body_size: String,
263
264 #[serde(default = "default_fetch_timeout")]
266 pub fetch_timeout: u64,
267
268 #[serde(default)]
270 pub source_viewer: bool,
271
272 #[serde(default = "default_css_mode")]
276 pub css: String,
277
278 #[serde(default)]
283 pub allow_private_fetch: bool,
284
285 #[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#[derive(Debug, Deserialize, Clone)]
328#[serde(untagged)]
329pub enum DataSource {
330 Url {
332 url: String,
333 #[serde(default = "default_cache_ttl")]
334 cache: u64,
335 },
336 File {
338 file: String,
339 #[serde(default = "default_cache_ttl")]
340 cache: u64,
341 },
342 SimplePath(String),
344}
345
346fn default_cache_ttl() -> u64 {
347 300 }
349
350#[derive(Debug, Deserialize, Clone)]
352pub struct CacheConfig {
353 #[serde(default = "default_cache_enabled")]
355 pub enabled: bool,
356
357 #[serde(default = "default_cache_ttl")]
359 pub ttl: u64,
360
361 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#[derive(Debug, Deserialize, Clone)]
381pub struct SessionConfig {
382 #[serde(default = "default_session_enabled")]
384 pub enabled: bool,
385
386 #[serde(default = "default_session_store")]
388 pub store: String,
389
390 #[serde(default = "default_cookie_name")]
392 pub cookie_name: String,
393
394 #[serde(default = "default_session_max_age")]
396 pub max_age: i64,
397
398 #[serde(default = "default_session_secure")]
400 pub secure: bool,
401
402 #[serde(default = "default_session_database")]
404 pub database: String,
405
406 #[serde(default)]
408 pub cloudflare: Option<CloudflareKvConfig>,
409}
410
411#[derive(Debug, Deserialize, Clone)]
413pub struct CloudflareKvConfig {
414 pub account_id: String,
416 pub namespace_id: String,
418 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 }
451
452fn default_session_secure() -> bool {
453 true
454}
455
456fn default_session_database() -> String {
457 "sessions.db".to_string()
458}
459
460#[derive(Debug, Deserialize, Clone)]
462pub struct AuthConfig {
463 #[serde(default)]
465 pub enabled: bool,
466
467 pub login_endpoint: Option<String>,
469
470 pub logout_endpoint: Option<String>,
472
473 #[serde(default = "default_jwt_cookie_name")]
475 pub jwt_cookie_name: String,
476
477 #[serde(default = "default_after_login")]
479 pub after_login: String,
480
481 #[serde(default = "default_login_path")]
483 pub login_path: String,
484
485 #[serde(default)]
487 pub protected_paths: Vec<String>,
488
489 pub jwt_secret: Option<String>,
491
492 #[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#[derive(Debug, Deserialize, Clone)]
536pub struct UploadConfig {
537 #[serde(default)]
539 pub enabled: bool,
540
541 #[serde(default = "default_upload_provider")]
543 pub provider: String,
544
545 #[serde(default = "default_upload_directory")]
547 pub directory: String,
548
549 #[serde(default = "default_upload_max_size")]
551 pub max_size: String,
552
553 #[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 pub fn max_size_bytes(&self) -> usize {
585 parse_size_string(&self.max_size)
586 }
587
588 pub fn is_type_allowed(&self, content_type: &str) -> bool {
590 if self.allowed_types.is_empty() {
591 return true; }
593 for allowed in &self.allowed_types {
594 if allowed == content_type {
595 return true;
596 }
597 if allowed.ends_with("/*") {
599 let prefix = &allowed[..allowed.len() - 1];
600 if content_type.starts_with(prefix) {
601 return true;
602 }
603 }
604 if allowed.starts_with('.') {
606 if mime_matches_extension(content_type, allowed) {
607 return true;
608 }
609 }
610 }
611 false
612 }
613}
614
615pub 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) }
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#[derive(Debug, Deserialize, Clone)]
650pub struct RateLimitConfig {
651 #[serde(default = "default_rate_limit_enabled")]
653 pub enabled: bool,
654
655 #[serde(default = "default_rate_limit_login")]
657 pub login: String,
658
659 #[serde(default = "default_rate_limit_upload")]
661 pub upload: String,
662
663 #[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 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#[derive(Debug, Deserialize, Clone)]
711pub struct EmailConfig {
712 pub from: String,
714
715 #[serde(default)]
717 pub from_name: Option<String>,
718
719 pub smtp: Option<SmtpConfig>,
721
722 pub api: Option<EmailApiConfig>,
724
725 #[serde(default = "default_email_template_dir")]
727 pub template_dir: String,
728}
729
730#[derive(Debug, Deserialize, Clone)]
732pub struct SmtpConfig {
733 pub host: String,
735
736 #[serde(default = "default_smtp_port")]
738 pub port: u16,
739
740 pub username: Option<String>,
742
743 pub password: Option<String>,
745}
746
747#[derive(Debug, Deserialize, Clone)]
749pub struct EmailApiConfig {
750 pub provider: String,
752
753 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
765pub 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 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 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 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 #[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); assert!(session.secure); 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 #[test]
908 fn test_parse_empty_toml() {
909 let config: Config = toml::from_str("").unwrap();
910 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"); assert!(config.data.is_empty()); assert!(config.cache.enabled); assert!(config.session.enabled); assert!(!config.auth.enabled); }
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 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 assert_eq!(config.server.port, 8085);
1032 }
1033
1034 #[test]
1035 fn test_session_secure_defaults_true() {
1036 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 #[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); }
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); }
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 #[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 #[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 #[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}