use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::Result;
#[derive(Debug, Deserialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub data: HashMap<String, DataSource>,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub session: SessionConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub uploads: UploadConfig,
#[serde(default)]
pub database: Option<DatabaseConfig>,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub email: Option<EmailConfig>,
#[serde(default)]
pub redirects: HashMap<String, String>,
#[serde(default)]
pub strict: bool,
#[serde(default)]
pub cloudflare: Option<CloudflareConfig>,
#[serde(default)]
pub supabase: Option<SupabaseConfig>,
#[serde(default)]
pub datasources: HashMap<String, DatasourceConfig>,
#[serde(default)]
pub collections: HashMap<String, CollectionPolicyConfig>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct CollectionPolicyConfig {
pub owner: Option<String>,
pub create: Option<String>,
pub update: Option<String>,
pub delete: Option<String>,
pub read: Option<String>,
pub filter: Option<String>,
#[serde(default)]
pub fields: FieldRulesConfig,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct FieldRulesConfig {
#[serde(default)]
pub readonly: Vec<String>,
#[serde(default)]
pub private: Vec<String>,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DatasourceType {
Api,
D1,
Supabase,
Sqlite,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatasourceConfig {
pub r#type: DatasourceType,
pub url: Option<String>,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
pub account_id: Option<String>,
pub api_token: Option<String>,
pub d1_database_id: Option<String>,
pub project_url: Option<String>,
pub api_key: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct CloudflareConfig {
pub account_id: String,
pub api_token: String,
pub d1_database_id: Option<String>,
pub r2_bucket: Option<String>,
pub r2_public_url: Option<String>,
pub turnstile_site_key: Option<String>,
pub turnstile_secret_key: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SupabaseConfig {
pub project_url: String,
pub api_key: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
#[serde(default = "default_db_type")]
pub r#type: String,
#[serde(default = "default_db_path")]
pub path: String,
}
fn default_db_type() -> String {
"sqlite".to_string()
}
fn default_db_path() -> String {
"data/app.db".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_max_body_size")]
pub max_body_size: String,
#[serde(default = "default_fetch_timeout")]
pub fetch_timeout: u64,
#[serde(default)]
pub source_viewer: bool,
#[serde(default = "default_css_mode")]
pub css: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: default_port(),
host: default_host(),
max_body_size: default_max_body_size(),
fetch_timeout: default_fetch_timeout(),
source_viewer: false,
css: default_css_mode(),
}
}
}
fn default_css_mode() -> String {
"full".to_string()
}
fn default_fetch_timeout() -> u64 {
10
}
fn default_max_body_size() -> String {
"10mb".to_string()
}
fn default_port() -> u16 {
8085
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum DataSource {
Url {
url: String,
#[serde(default = "default_cache_ttl")]
cache: u64,
},
File {
file: String,
#[serde(default = "default_cache_ttl")]
cache: u64,
},
SimplePath(String),
}
fn default_cache_ttl() -> u64 {
300 }
#[derive(Debug, Deserialize, Clone)]
pub struct CacheConfig {
#[serde(default = "default_cache_enabled")]
pub enabled: bool,
#[serde(default = "default_cache_ttl")]
pub ttl: u64,
pub redis_url: Option<String>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: default_cache_enabled(),
ttl: default_cache_ttl(),
redis_url: None,
}
}
}
fn default_cache_enabled() -> bool {
true
}
#[derive(Debug, Deserialize, Clone)]
pub struct SessionConfig {
#[serde(default = "default_session_enabled")]
pub enabled: bool,
#[serde(default = "default_session_store")]
pub store: String,
#[serde(default = "default_cookie_name")]
pub cookie_name: String,
#[serde(default = "default_session_max_age")]
pub max_age: i64,
#[serde(default = "default_session_secure")]
pub secure: bool,
#[serde(default = "default_session_database")]
pub database: String,
#[serde(default)]
pub cloudflare: Option<CloudflareKvConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct CloudflareKvConfig {
pub account_id: String,
pub namespace_id: String,
pub api_token: String,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
enabled: default_session_enabled(),
store: default_session_store(),
cookie_name: default_cookie_name(),
max_age: default_session_max_age(),
secure: default_session_secure(),
database: default_session_database(),
cloudflare: None,
}
}
}
fn default_session_enabled() -> bool {
true
}
fn default_session_store() -> String {
"sqlite".to_string()
}
fn default_cookie_name() -> String {
"w_session".to_string()
}
fn default_session_max_age() -> i64 {
604800 }
fn default_session_secure() -> bool {
true
}
fn default_session_database() -> String {
"sessions.db".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
pub login_endpoint: Option<String>,
pub logout_endpoint: Option<String>,
#[serde(default = "default_jwt_cookie_name")]
pub jwt_cookie_name: String,
#[serde(default = "default_after_login")]
pub after_login: String,
#[serde(default = "default_login_path")]
pub login_path: String,
#[serde(default)]
pub protected_paths: Vec<String>,
pub jwt_secret: Option<String>,
#[serde(default = "default_jwt_claims")]
pub jwt_claims: Vec<String>,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
login_endpoint: None,
logout_endpoint: None,
jwt_cookie_name: default_jwt_cookie_name(),
after_login: default_after_login(),
login_path: default_login_path(),
protected_paths: vec![],
jwt_secret: None,
jwt_claims: default_jwt_claims(),
}
}
}
fn default_jwt_cookie_name() -> String {
"w_token".to_string()
}
fn default_after_login() -> String {
"/".to_string()
}
fn default_login_path() -> String {
"/login".to_string()
}
fn default_jwt_claims() -> Vec<String> {
vec![
"id".to_string(),
"user_id".to_string(),
"email".to_string(),
"full_name".to_string(),
]
}
#[derive(Debug, Deserialize, Clone)]
pub struct UploadConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_upload_provider")]
pub provider: String,
#[serde(default = "default_upload_directory")]
pub directory: String,
#[serde(default = "default_upload_max_size")]
pub max_size: String,
#[serde(default)]
pub allowed_types: Vec<String>,
}
impl Default for UploadConfig {
fn default() -> Self {
Self {
enabled: false,
provider: default_upload_provider(),
directory: default_upload_directory(),
max_size: default_upload_max_size(),
allowed_types: vec![],
}
}
}
fn default_upload_provider() -> String {
"local".to_string()
}
fn default_upload_directory() -> String {
"uploads".to_string()
}
fn default_upload_max_size() -> String {
"10mb".to_string()
}
impl UploadConfig {
pub fn max_size_bytes(&self) -> usize {
parse_size_string(&self.max_size)
}
pub fn is_type_allowed(&self, content_type: &str) -> bool {
if self.allowed_types.is_empty() {
return true; }
for allowed in &self.allowed_types {
if allowed == content_type {
return true;
}
if allowed.ends_with("/*") {
let prefix = &allowed[..allowed.len() - 1];
if content_type.starts_with(prefix) {
return true;
}
}
if allowed.starts_with('.') {
if mime_matches_extension(content_type, allowed) {
return true;
}
}
}
false
}
}
pub fn parse_size_string(s: &str) -> usize {
let s = s.trim().to_lowercase();
if let Some(num) = s.strip_suffix("gb") {
num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024
} else if let Some(num) = s.strip_suffix("mb") {
num.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024
} else if let Some(num) = s.strip_suffix("kb") {
num.trim().parse::<usize>().unwrap_or(0) * 1024
} else {
s.parse::<usize>().unwrap_or(10 * 1024 * 1024) }
}
fn mime_matches_extension(content_type: &str, extension: &str) -> bool {
match extension {
".pdf" => content_type == "application/pdf",
".doc" => content_type == "application/msword",
".docx" => {
content_type
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
".xls" => content_type == "application/vnd.ms-excel",
".xlsx" => {
content_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
".csv" => content_type == "text/csv",
".txt" => content_type == "text/plain",
".zip" => content_type == "application/zip",
_ => false,
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct RateLimitConfig {
#[serde(default = "default_rate_limit_enabled")]
pub enabled: bool,
#[serde(default = "default_rate_limit_login")]
pub login: String,
#[serde(default = "default_rate_limit_upload")]
pub upload: String,
#[serde(default = "default_rate_limit_action")]
pub action: String,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: default_rate_limit_enabled(),
login: default_rate_limit_login(),
upload: default_rate_limit_upload(),
action: default_rate_limit_action(),
}
}
}
fn default_rate_limit_enabled() -> bool {
true
}
fn default_rate_limit_login() -> String {
"5/60".to_string()
}
fn default_rate_limit_upload() -> String {
"10/60".to_string()
}
fn default_rate_limit_action() -> String {
"30/60".to_string()
}
impl RateLimitConfig {
pub fn parse_limit(s: &str) -> (u32, u64) {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() == 2 {
let max = parts[0].trim().parse().unwrap_or(5);
let window = parts[1].trim().parse().unwrap_or(60);
(max, window)
} else {
(5, 60)
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct EmailConfig {
pub from: String,
#[serde(default)]
pub from_name: Option<String>,
pub smtp: Option<SmtpConfig>,
pub api: Option<EmailApiConfig>,
#[serde(default = "default_email_template_dir")]
pub template_dir: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SmtpConfig {
pub host: String,
#[serde(default = "default_smtp_port")]
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct EmailApiConfig {
pub provider: String,
pub api_key: String,
}
fn default_email_template_dir() -> String {
"emails".to_string()
}
fn default_smtp_port() -> u16 {
587
}
pub fn resolve_config_path(project_dir: &Path) -> PathBuf {
let canonical = project_dir.join("what.toml");
if canonical.exists() {
return canonical;
}
let legacy = project_dir.join("wwwhat.toml");
if legacy.exists() {
return legacy;
}
canonical
}
impl Config {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let file_name = path
.as_ref()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "what.toml".to_string());
let content = std::fs::read_to_string(path)?;
let de = toml::de::Deserializer::new(&content);
let mut unknown_keys: Vec<String> = Vec::new();
let config: Config = serde_ignored::deserialize(de, |ignored_path| {
unknown_keys.push(ignored_path.to_string());
})?;
for key in unknown_keys {
tracing::warn!(
"{}: unknown key '{}' is ignored — possible typo? The default value applies.",
file_name,
key
);
}
Ok(config)
}
pub fn load_from_current_dir() -> Result<Self> {
let path = resolve_config_path(&std::env::current_dir()?);
if path.exists() {
Self::load(path)
} else {
Ok(Config::default())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_tolerates_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("what.toml");
std::fs::write(&path, "[server]\nprt = 3000\nport = 9090\n\n[nonsense]\nfoo = 1\n")
.unwrap();
let config = Config::load(&path).unwrap();
assert_eq!(config.server.port, 9090);
}
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.server.port, 8085);
assert_eq!(config.server.host, "127.0.0.1");
assert!(config.data.is_empty());
assert!(config.cache.enabled);
assert_eq!(config.cache.ttl, 300);
assert!(config.cache.redis_url.is_none());
assert!(config.session.enabled);
assert_eq!(config.session.cookie_name, "w_session");
assert_eq!(config.session.max_age, 604800);
assert!(config.session.secure);
assert_eq!(config.session.database, "sessions.db");
assert!(!config.auth.enabled);
assert!(config.auth.login_endpoint.is_none());
assert!(config.auth.logout_endpoint.is_none());
assert_eq!(config.auth.jwt_cookie_name, "w_token");
assert_eq!(config.auth.after_login, "/");
assert_eq!(config.auth.login_path, "/login");
assert!(config.auth.protected_paths.is_empty());
assert!(config.auth.jwt_secret.is_none());
assert_eq!(
config.auth.jwt_claims,
vec!["id", "user_id", "email", "full_name"]
);
}
#[test]
fn test_server_config_default() {
let server = ServerConfig::default();
assert_eq!(server.port, 8085);
assert_eq!(server.host, "127.0.0.1");
}
#[test]
fn test_cache_config_default() {
let cache = CacheConfig::default();
assert!(cache.enabled);
assert_eq!(cache.ttl, 300);
assert!(cache.redis_url.is_none());
}
#[test]
fn test_session_config_default() {
let session = SessionConfig::default();
assert!(session.enabled);
assert_eq!(session.store, "sqlite");
assert_eq!(session.cookie_name, "w_session");
assert_eq!(session.max_age, 604800); assert!(session.secure); assert_eq!(session.database, "sessions.db");
assert!(session.cloudflare.is_none());
}
#[test]
fn test_auth_config_default() {
let auth = AuthConfig::default();
assert!(!auth.enabled);
assert!(auth.login_endpoint.is_none());
assert!(auth.logout_endpoint.is_none());
assert_eq!(auth.jwt_cookie_name, "w_token");
assert_eq!(auth.after_login, "/");
assert_eq!(auth.login_path, "/login");
assert!(auth.protected_paths.is_empty());
assert!(auth.jwt_secret.is_none());
}
#[test]
fn test_parse_empty_toml() {
let config: Config = toml::from_str("").unwrap();
assert_eq!(config.server.port, 8085);
assert_eq!(config.server.host, "127.0.0.1");
assert!(config.data.is_empty());
assert!(config.cache.enabled);
}
#[test]
fn test_parse_full_config() {
let toml_str = r#"
[server]
port = 3000
host = "0.0.0.0"
[data.products]
url = "https://api.example.com/products"
cache = 600
[data.posts]
file = "data/posts.json"
cache = 120
[data]
simple = "data/items.json"
[cache]
enabled = false
ttl = 60
redis_url = "redis://localhost:6379"
[session]
enabled = false
cookie_name = "my_session"
max_age = 3600
secure = true
database = "my_sessions.db"
[auth]
enabled = true
login_endpoint = "https://api.example.com/auth/login"
logout_endpoint = "https://api.example.com/auth/logout"
jwt_cookie_name = "my_token"
after_login = "/dashboard"
login_path = "/signin"
protected_paths = ["/admin/*", "/dashboard/*"]
jwt_secret = "supersecret"
jwt_claims = ["id", "email", "role"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.server.port, 3000);
assert_eq!(config.server.host, "0.0.0.0");
assert!(!config.cache.enabled);
assert_eq!(config.cache.ttl, 60);
assert_eq!(
config.cache.redis_url.as_deref(),
Some("redis://localhost:6379")
);
assert!(!config.session.enabled);
assert_eq!(config.session.cookie_name, "my_session");
assert_eq!(config.session.max_age, 3600);
assert!(config.session.secure);
assert_eq!(config.session.database, "my_sessions.db");
assert!(config.auth.enabled);
assert_eq!(
config.auth.login_endpoint.as_deref(),
Some("https://api.example.com/auth/login")
);
assert_eq!(
config.auth.logout_endpoint.as_deref(),
Some("https://api.example.com/auth/logout")
);
assert_eq!(config.auth.jwt_cookie_name, "my_token");
assert_eq!(config.auth.after_login, "/dashboard");
assert_eq!(config.auth.login_path, "/signin");
assert_eq!(
config.auth.protected_paths,
vec!["/admin/*", "/dashboard/*"]
);
assert_eq!(config.auth.jwt_secret.as_deref(), Some("supersecret"));
assert_eq!(config.auth.jwt_claims, vec!["id", "email", "role"]);
}
#[test]
fn test_parse_partial_config_only_server() {
let toml_str = r#"
[server]
port = 9090
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.server.port, 9090);
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); }
#[test]
fn test_parse_partial_config_only_auth() {
let toml_str = r#"
[auth]
enabled = true
login_endpoint = "https://api.example.com/login"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.auth.enabled);
assert_eq!(
config.auth.login_endpoint.as_deref(),
Some("https://api.example.com/login")
);
assert_eq!(config.auth.jwt_cookie_name, "w_token");
assert_eq!(config.auth.after_login, "/");
assert_eq!(config.auth.login_path, "/login");
assert_eq!(config.server.port, 8085);
}
#[test]
fn test_session_secure_defaults_true() {
let config: Config = toml::from_str("").unwrap();
assert!(config.session.secure);
}
#[test]
fn test_session_secure_can_be_disabled() {
let toml_str = r#"
[session]
secure = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(!config.session.secure);
}
#[test]
fn test_data_source_url_variant() {
let toml_str = r#"
[data.api]
url = "https://api.example.com/data"
cache = 120
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let source = config.data.get("api").expect("data.api should exist");
match source {
DataSource::Url { url, cache } => {
assert_eq!(url, "https://api.example.com/data");
assert_eq!(*cache, 120);
}
_ => panic!("Expected DataSource::Url variant"),
}
}
#[test]
fn test_data_source_url_default_cache() {
let toml_str = r#"
[data.api]
url = "https://api.example.com/data"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let source = config.data.get("api").unwrap();
match source {
DataSource::Url { cache, .. } => {
assert_eq!(*cache, 300); }
_ => panic!("Expected DataSource::Url variant"),
}
}
#[test]
fn test_data_source_file_variant() {
let toml_str = r#"
[data.local]
file = "data/products.json"
cache = 60
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let source = config.data.get("local").unwrap();
match source {
DataSource::File { file, cache } => {
assert_eq!(file, "data/products.json");
assert_eq!(*cache, 60);
}
_ => panic!("Expected DataSource::File variant"),
}
}
#[test]
fn test_data_source_file_default_cache() {
let toml_str = r#"
[data.local]
file = "data/products.json"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let source = config.data.get("local").unwrap();
match source {
DataSource::File { cache, .. } => {
assert_eq!(*cache, 300); }
_ => panic!("Expected DataSource::File variant"),
}
}
#[test]
fn test_data_source_simple_path_variant() {
let toml_str = r#"
[data]
items = "data/items.json"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let source = config.data.get("items").unwrap();
match source {
DataSource::SimplePath(path) => {
assert_eq!(path, "data/items.json");
}
_ => panic!("Expected DataSource::SimplePath variant"),
}
}
#[test]
fn test_multiple_data_sources() {
let toml_str = r#"
[data]
simple = "data/simple.json"
[data.api]
url = "https://api.example.com"
[data.local]
file = "data/local.json"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.data.len(), 3);
assert!(config.data.contains_key("simple"));
assert!(config.data.contains_key("api"));
assert!(config.data.contains_key("local"));
}
#[test]
fn test_datasources_default_empty() {
let config = Config::default();
assert!(config.datasources.is_empty());
}
#[test]
fn test_parse_datasources_all_types() {
let toml_str = r##"
[datasources.users]
type = "supabase"
project_url = "https://xxx.supabase.co"
api_key = "sk-xxx"
[datasources.content]
type = "d1"
account_id = "abc123"
api_token = "token123"
d1_database_id = "db-456"
[datasources.inventory]
type = "api"
url = "https://inventory.example.com"
headers = { Authorization = "Bearer tok123" }
[datasources.local_extra]
type = "sqlite"
path = "data/extra.db"
"##;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.datasources.len(), 4);
let users = &config.datasources["users"];
assert_eq!(users.r#type, DatasourceType::Supabase);
assert_eq!(
users.project_url.as_deref(),
Some("https://xxx.supabase.co")
);
assert_eq!(users.api_key.as_deref(), Some("sk-xxx"));
let content = &config.datasources["content"];
assert_eq!(content.r#type, DatasourceType::D1);
assert_eq!(content.account_id.as_deref(), Some("abc123"));
assert_eq!(content.d1_database_id.as_deref(), Some("db-456"));
let inventory = &config.datasources["inventory"];
assert_eq!(inventory.r#type, DatasourceType::Api);
assert_eq!(
inventory.url.as_deref(),
Some("https://inventory.example.com")
);
let headers = inventory.headers.as_ref().unwrap();
assert_eq!(headers["Authorization"], "Bearer tok123");
let local = &config.datasources["local_extra"];
assert_eq!(local.r#type, DatasourceType::Sqlite);
assert_eq!(local.path.as_deref(), Some("data/extra.db"));
}
#[test]
fn test_load_nonexistent_file() {
let result = Config::load("/nonexistent/path/what.toml");
assert!(result.is_err());
}
#[test]
fn test_invalid_toml_parsing() {
let bad_toml = "this is not [valid toml ===";
let result: std::result::Result<Config, _> = toml::from_str(bad_toml);
assert!(result.is_err());
}
#[test]
fn test_config_is_cloneable() {
let config = Config::default();
let cloned = config.clone();
assert_eq!(cloned.server.port, config.server.port);
assert_eq!(cloned.server.host, config.server.host);
}
#[test]
fn test_config_is_debuggable() {
let config = Config::default();
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("Config"));
assert!(debug_str.contains("8085"));
}
#[test]
fn test_invalid_datasource_type_rejected() {
let toml_str = r#"
[datasources.bad]
type = "mongodb"
url = "https://example.com"
"#;
let result: std::result::Result<Config, _> = toml::from_str(toml_str);
assert!(
result.is_err(),
"Unknown datasource type should fail deserialization"
);
}
#[test]
fn test_datasource_type_case_sensitive() {
let toml_str = r#"
[datasources.bad]
type = "Supabase"
project_url = "https://xxx.supabase.co"
api_key = "key"
"#;
let result: std::result::Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "Datasource type must be lowercase");
}
#[test]
fn test_datasource_missing_type_field() {
let toml_str = r#"
[datasources.notype]
url = "https://example.com"
"#;
let result: std::result::Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "Datasource without type field should fail");
}
}