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 #[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#[derive(Debug, Deserialize, Clone)]
338#[serde(untagged)]
339pub enum DataSource {
340 Url {
342 url: String,
343 #[serde(default = "default_cache_ttl")]
344 cache: u64,
345 },
346 File {
348 file: String,
349 #[serde(default = "default_cache_ttl")]
350 cache: u64,
351 },
352 SimplePath(String),
354}
355
356fn default_cache_ttl() -> u64 {
357 300 }
359
360#[derive(Debug, Deserialize, Clone)]
362pub struct CacheConfig {
363 #[serde(default = "default_cache_enabled")]
365 pub enabled: bool,
366
367 #[serde(default = "default_cache_ttl")]
369 pub ttl: u64,
370
371 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#[derive(Debug, Deserialize, Clone)]
391pub struct SessionConfig {
392 #[serde(default = "default_session_enabled")]
394 pub enabled: bool,
395
396 #[serde(default = "default_session_store")]
398 pub store: String,
399
400 #[serde(default = "default_cookie_name")]
402 pub cookie_name: String,
403
404 #[serde(default = "default_session_max_age")]
406 pub max_age: i64,
407
408 #[serde(default = "default_session_secure")]
410 pub secure: bool,
411
412 #[serde(default = "default_session_database")]
414 pub database: String,
415
416 #[serde(default)]
418 pub cloudflare: Option<CloudflareKvConfig>,
419}
420
421#[derive(Debug, Deserialize, Clone)]
423pub struct CloudflareKvConfig {
424 pub account_id: String,
426 pub namespace_id: String,
428 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 }
461
462fn default_session_secure() -> bool {
463 true
464}
465
466fn default_session_database() -> String {
467 "sessions.db".to_string()
468}
469
470#[derive(Debug, Deserialize, Clone)]
472pub struct AuthConfig {
473 #[serde(default)]
475 pub enabled: bool,
476
477 pub login_endpoint: Option<String>,
479
480 pub logout_endpoint: Option<String>,
482
483 #[serde(default = "default_jwt_cookie_name")]
485 pub jwt_cookie_name: String,
486
487 #[serde(default = "default_after_login")]
489 pub after_login: String,
490
491 #[serde(default = "default_login_path")]
493 pub login_path: String,
494
495 #[serde(default)]
497 pub protected_paths: Vec<String>,
498
499 pub jwt_secret: Option<String>,
501
502 #[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#[derive(Debug, Deserialize, Clone)]
546pub struct UploadConfig {
547 #[serde(default)]
549 pub enabled: bool,
550
551 #[serde(default = "default_upload_provider")]
553 pub provider: String,
554
555 #[serde(default = "default_upload_directory")]
557 pub directory: String,
558
559 #[serde(default = "default_upload_max_size")]
561 pub max_size: String,
562
563 #[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 pub fn max_size_bytes(&self) -> usize {
595 parse_size_string(&self.max_size)
596 }
597
598 pub fn is_type_allowed(&self, content_type: &str) -> bool {
600 if self.allowed_types.is_empty() {
601 return true; }
603 for allowed in &self.allowed_types {
604 if allowed == content_type {
605 return true;
606 }
607 if allowed.ends_with("/*") {
609 let prefix = &allowed[..allowed.len() - 1];
610 if content_type.starts_with(prefix) {
611 return true;
612 }
613 }
614 if allowed.starts_with('.') {
616 if mime_matches_extension(content_type, allowed) {
617 return true;
618 }
619 }
620 }
621 false
622 }
623}
624
625pub 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) }
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#[derive(Debug, Deserialize, Clone)]
660pub struct RateLimitConfig {
661 #[serde(default = "default_rate_limit_enabled")]
663 pub enabled: bool,
664
665 #[serde(default = "default_rate_limit_login")]
667 pub login: String,
668
669 #[serde(default = "default_rate_limit_upload")]
671 pub upload: String,
672
673 #[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 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#[derive(Debug, Deserialize, Clone)]
721pub struct EmailConfig {
722 pub from: String,
724
725 #[serde(default)]
727 pub from_name: Option<String>,
728
729 pub smtp: Option<SmtpConfig>,
731
732 pub api: Option<EmailApiConfig>,
734
735 #[serde(default = "default_email_template_dir")]
737 pub template_dir: String,
738}
739
740#[derive(Debug, Deserialize, Clone)]
742pub struct SmtpConfig {
743 pub host: String,
745
746 #[serde(default = "default_smtp_port")]
748 pub port: u16,
749
750 pub username: Option<String>,
752
753 pub password: Option<String>,
755}
756
757#[derive(Debug, Deserialize, Clone)]
759pub struct EmailApiConfig {
760 pub provider: String,
762
763 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
775pub 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 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 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 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 #[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); assert!(session.secure); 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 #[test]
918 fn test_parse_empty_toml() {
919 let config: Config = toml::from_str("").unwrap();
920 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"); assert!(config.data.is_empty()); assert!(config.cache.enabled); assert!(config.session.enabled); assert!(!config.auth.enabled); }
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 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 assert_eq!(config.server.port, 8085);
1042 }
1043
1044 #[test]
1045 fn test_session_secure_defaults_true() {
1046 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 #[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); }
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); }
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 #[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 #[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 #[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}