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