1use crate::error::{DataError, DataResult};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum AdapterKind {
7 None,
8 Postgres,
9 MySql,
10 Sqlite,
11 SingleStore,
12 ClickHouse,
13 BigQuery,
14 OpenSearch,
15}
16
17impl AdapterKind {
18 pub fn as_str(self) -> &'static str {
19 match self {
20 Self::None => "none",
21 Self::Postgres => "postgres",
22 Self::MySql => "mysql",
23 Self::Sqlite => "sqlite",
24 Self::SingleStore => "singlestore",
25 Self::ClickHouse => "clickhouse",
26 Self::BigQuery => "bigquery",
27 Self::OpenSearch => "opensearch",
28 }
29 }
30
31 pub fn parse(raw: &str) -> DataResult<Self> {
32 match raw.trim().to_ascii_lowercase().as_str() {
33 "none" => Ok(Self::None),
34 "postgres" | "postgresql" | "pg" => Ok(Self::Postgres),
35 "mysql" => Ok(Self::MySql),
36 "sqlite" | "sqlite3" => Ok(Self::Sqlite),
37 "singlestore" | "single_store" | "memsql" => Ok(Self::SingleStore),
38 "clickhouse" | "click_house" => Ok(Self::ClickHouse),
39 "bigquery" | "big_query" | "bq" => Ok(Self::BigQuery),
40 "opensearch" | "open_search" => Ok(Self::OpenSearch),
41 value => Err(DataError::Config(format!(
42 "unsupported database adapter `{value}`; expected one of: none, postgres, mysql, sqlite, singlestore, clickhouse, bigquery, opensearch"
43 ))),
44 }
45 }
46
47 pub fn is_sql_backend(self) -> bool {
48 matches!(
49 self,
50 Self::Postgres
51 | Self::MySql
52 | Self::Sqlite
53 | Self::SingleStore
54 | Self::ClickHouse
55 | Self::BigQuery
56 )
57 }
58
59 pub fn supports_migrations(self) -> bool {
60 self.is_sql_backend()
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct DatabaseConfig {
66 pub adapter: AdapterKind,
67 pub url: Option<String>,
68 pub url_env: Option<String>,
69}
70
71impl Default for DatabaseConfig {
72 fn default() -> Self {
73 Self {
74 adapter: AdapterKind::None,
75 url: None,
76 url_env: Some("DATABASE_URL".to_string()),
77 }
78 }
79}
80
81impl DatabaseConfig {
82 pub fn from_toml_like_str(content: &str) -> DataResult<Self> {
83 let mut config = Self::default();
84 let mut in_database_section = false;
85
86 for raw_line in content.lines() {
87 let line = raw_line.trim();
88 if line.is_empty() || line.starts_with('#') {
89 continue;
90 }
91 if line.starts_with('[') && line.ends_with(']') {
92 in_database_section = line == "[database]";
93 continue;
94 }
95 if !in_database_section {
96 continue;
97 }
98
99 let Some((key, value)) = line.split_once('=') else {
100 continue;
101 };
102
103 let key = key.trim();
104 let value = strip_quotes(value.trim());
105 match key {
106 "adapter" => config.adapter = AdapterKind::parse(value)?,
107 "url" => config.url = Some(value.to_string()),
108 "url_env" => config.url_env = Some(value.to_string()),
109 _ => {}
110 }
111 }
112
113 Ok(config)
114 }
115
116 pub fn resolve_url(&self) -> Option<String> {
117 if let Some(url) = &self.url {
118 return Some(url.clone());
119 }
120 self.url_env
121 .as_deref()
122 .and_then(|env_name| std::env::var(env_name).ok())
123 }
124}
125
126fn strip_quotes(value: &str) -> &str {
127 value
128 .strip_prefix('"')
129 .and_then(|rest| rest.strip_suffix('"'))
130 .unwrap_or(value)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::{AdapterKind, DatabaseConfig};
136 use proptest::prelude::*;
137 use std::sync::atomic::{AtomicU64, Ordering};
138
139 static TEST_ENV_COUNTER: AtomicU64 = AtomicU64::new(0);
140
141 #[test]
142 fn parse_database_config() {
143 let config = DatabaseConfig::from_toml_like_str(
144 r#"
145[database]
146adapter = "postgres"
147url_env = "APP_DB_URL"
148"#,
149 )
150 .unwrap();
151
152 assert_eq!(config.adapter, AdapterKind::Postgres);
153 assert_eq!(config.url_env.as_deref(), Some("APP_DB_URL"));
154 }
155
156 #[test]
157 fn resolve_url_prefers_inline_then_env() {
158 let inline = DatabaseConfig {
159 adapter: AdapterKind::Sqlite,
160 url: Some("sqlite://inline.db".to_string()),
161 url_env: Some("IGNORED_ENV".to_string()),
162 };
163 assert_eq!(inline.resolve_url().as_deref(), Some("sqlite://inline.db"));
164
165 let key = format!(
166 "SHELLY_DATA_TEST_DB_URL_{}",
167 TEST_ENV_COUNTER.fetch_add(1, Ordering::Relaxed)
168 );
169 std::env::set_var(&key, "sqlite://from-env.db");
170 let from_env = DatabaseConfig {
171 adapter: AdapterKind::Sqlite,
172 url: None,
173 url_env: Some(key.clone()),
174 };
175 assert_eq!(
176 from_env.resolve_url().as_deref(),
177 Some("sqlite://from-env.db")
178 );
179 std::env::remove_var(key);
180 }
181
182 proptest! {
183 #[test]
184 fn adapter_parse_accepts_aliases_case_and_whitespace(
185 alias in prop_oneof![
186 Just("none"),
187 Just("postgres"),
188 Just("postgresql"),
189 Just("pg"),
190 Just("mysql"),
191 Just("sqlite"),
192 Just("sqlite3"),
193 Just("singlestore"),
194 Just("single_store"),
195 Just("memsql"),
196 Just("clickhouse"),
197 Just("click_house"),
198 Just("bigquery"),
199 Just("big_query"),
200 Just("bq"),
201 Just("opensearch"),
202 Just("open_search"),
203 ],
204 left_ws in 0usize..3,
205 right_ws in 0usize..3,
206 uppercase in any::<bool>(),
207 ) {
208 let alias = if uppercase {
209 alias.to_ascii_uppercase()
210 } else {
211 alias.to_string()
212 };
213 let input = format!("{}{}{}", " ".repeat(left_ws), alias, " ".repeat(right_ws));
214 let kind = AdapterKind::parse(&input).unwrap();
215 let expected = match alias.to_ascii_lowercase().as_str() {
216 "none" => AdapterKind::None,
217 "postgres" | "postgresql" | "pg" => AdapterKind::Postgres,
218 "mysql" => AdapterKind::MySql,
219 "sqlite" | "sqlite3" => AdapterKind::Sqlite,
220 "singlestore" | "single_store" | "memsql" => AdapterKind::SingleStore,
221 "clickhouse" | "click_house" => AdapterKind::ClickHouse,
222 "bigquery" | "big_query" | "bq" => AdapterKind::BigQuery,
223 "opensearch" | "open_search" => AdapterKind::OpenSearch,
224 _ => unreachable!("input generated from known aliases"),
225 };
226 prop_assert_eq!(kind, expected);
227 }
228
229 #[test]
230 fn adapter_parse_rejects_unknown_values(raw in "[a-zA-Z0-9_\\-]{1,24}") {
231 let normalized = raw.trim().to_ascii_lowercase();
232 prop_assume!(
233 normalized != "none" &&
234 normalized != "postgres" &&
235 normalized != "postgresql" &&
236 normalized != "pg" &&
237 normalized != "mysql" &&
238 normalized != "sqlite" &&
239 normalized != "sqlite3" &&
240 normalized != "singlestore" &&
241 normalized != "single_store" &&
242 normalized != "memsql" &&
243 normalized != "clickhouse" &&
244 normalized != "click_house" &&
245 normalized != "bigquery" &&
246 normalized != "big_query" &&
247 normalized != "bq" &&
248 normalized != "opensearch" &&
249 normalized != "open_search"
250 );
251 prop_assert!(AdapterKind::parse(&raw).is_err());
252 }
253 }
254}