Skip to main content

dbkit/
config.rs

1use crate::DbkitError;
2
3/// A supported transactional database backend, detected from the URL scheme.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Backend {
6    Postgres,
7    MySql,
8    Sqlite,
9}
10
11impl Backend {
12    /// Detect the backend from a connection URL's scheme
13    /// (`postgres://`, `mysql://`, `sqlite://`).
14    pub fn from_url(url: &str) -> Result<Self, DbkitError> {
15        match url.split(':').next().unwrap_or("") {
16            "postgres" | "postgresql" => Ok(Backend::Postgres),
17            "mysql" => Ok(Backend::MySql),
18            "sqlite" => Ok(Backend::Sqlite),
19            other => Err(DbkitError::UnsupportedBackend(other.to_string())),
20        }
21    }
22
23    /// The URL scheme used when building a URL from parts.
24    fn scheme(self) -> &'static str {
25        match self {
26            Backend::Postgres => "postgres",
27            Backend::MySql => "mysql",
28            Backend::Sqlite => "sqlite",
29        }
30    }
31
32    /// The default TCP port for server backends (0 for SQLite, which is fileless).
33    fn default_port(self) -> u16 {
34        match self {
35            Backend::Postgres => 5432,
36            Backend::MySql => 3306,
37            Backend::Sqlite => 0,
38        }
39    }
40}
41
42/// Configuration for a dbkit database connection.
43///
44/// Can be built from a URL string or constructed with the builder.
45///
46/// # Example
47/// ```
48/// use dbkit::DbkitConfig;
49///
50/// // From URL
51/// let config = DbkitConfig::from_url("postgres://localhost/mydb");
52///
53/// // From builder
54/// let config = DbkitConfig::builder()
55///     .host("db.example.com")
56///     .port(5432)
57///     .database("myapp")
58///     .user("admin")
59///     .password("secret")
60///     .pool_size(16)
61///     .connect_timeout_secs(10)
62///     .build();
63/// ```
64#[derive(Debug, Clone)]
65pub struct DbkitConfig {
66    /// Connection URL. The scheme selects the backend
67    /// (`postgres://`, `mysql://`, `sqlite://`).
68    pub url: String,
69    /// Maximum pool size. Default: 16.
70    pub pool_size: usize,
71    /// Connection timeout in seconds. Default: 30.
72    pub connect_timeout_secs: u64,
73    /// Idle timeout in seconds. Connections idle longer are reaped. Default: 300.
74    pub idle_timeout_secs: u64,
75    /// Auto-create the database if it doesn't exist. Default: true.
76    pub auto_create_db: bool,
77}
78
79impl DbkitConfig {
80    /// Create config from a connection URL with default settings.
81    pub fn from_url(url: &str) -> Self {
82        Self {
83            url: url.to_string(),
84            pool_size: 16,
85            connect_timeout_secs: 30,
86            idle_timeout_secs: 300,
87            auto_create_db: true,
88        }
89    }
90
91    /// Start building a config from individual connection parameters.
92    pub fn builder() -> ConfigBuilder {
93        ConfigBuilder::default()
94    }
95}
96
97/// Builder for constructing a [`DbkitConfig`] from individual parameters.
98pub struct ConfigBuilder {
99    backend: Backend,
100    host: String,
101    port: Option<u16>,
102    database: String,
103    user: Option<String>,
104    password: Option<String>,
105    pool_size: usize,
106    connect_timeout_secs: u64,
107    idle_timeout_secs: u64,
108    auto_create_db: bool,
109    ssl_mode: SslMode,
110}
111
112/// SSL mode for server backends (Postgres / MySQL).
113///
114/// [`ConfigBuilder::build`] renders this as the backend's own URL parameter —
115/// `sslmode=disable|prefer|require` for Postgres, `ssl-mode=DISABLED|PREFERRED|
116/// REQUIRED` for MySQL — so the built URL parses on either driver. The mode is
117/// always written explicitly (including the default `Disable`), because the
118/// drivers' own defaults differ from it (sqlx defaults to *prefer*).
119#[derive(Debug, Clone, Copy, Default)]
120pub enum SslMode {
121    /// No SSL (default — matches current NoTls behavior).
122    #[default]
123    Disable,
124    /// Prefer SSL but allow fallback.
125    Prefer,
126    /// Require SSL.
127    Require,
128}
129
130impl Default for ConfigBuilder {
131    fn default() -> Self {
132        Self {
133            backend: Backend::Postgres,
134            host: "localhost".into(),
135            port: None,
136            database: "postgres".into(),
137            user: None,
138            password: None,
139            pool_size: 16,
140            connect_timeout_secs: 30,
141            idle_timeout_secs: 300,
142            auto_create_db: true,
143            ssl_mode: SslMode::default(),
144        }
145    }
146}
147
148impl ConfigBuilder {
149    /// Select the target backend. Defaults to [`Backend::Postgres`].
150    pub fn backend(mut self, backend: Backend) -> Self {
151        self.backend = backend;
152        self
153    }
154
155    pub fn host(mut self, host: &str) -> Self {
156        self.host = host.to_string();
157        self
158    }
159
160    pub fn port(mut self, port: u16) -> Self {
161        self.port = Some(port);
162        self
163    }
164
165    pub fn database(mut self, database: &str) -> Self {
166        self.database = database.to_string();
167        self
168    }
169
170    pub fn user(mut self, user: &str) -> Self {
171        self.user = Some(user.to_string());
172        self
173    }
174
175    pub fn password(mut self, password: &str) -> Self {
176        self.password = Some(password.to_string());
177        self
178    }
179
180    pub fn pool_size(mut self, size: usize) -> Self {
181        self.pool_size = size;
182        self
183    }
184
185    pub fn connect_timeout_secs(mut self, secs: u64) -> Self {
186        self.connect_timeout_secs = secs;
187        self
188    }
189
190    pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
191        self.idle_timeout_secs = secs;
192        self
193    }
194
195    pub fn auto_create_db(mut self, enabled: bool) -> Self {
196        self.auto_create_db = enabled;
197        self
198    }
199
200    pub fn ssl_mode(mut self, mode: SslMode) -> Self {
201        self.ssl_mode = mode;
202        self
203    }
204
205    /// Build the config, constructing the connection URL from parts.
206    ///
207    /// For [`Backend::Sqlite`] the URL is `sqlite://{database}`, treating
208    /// `database` as a file path; host/port/auth/SSL are ignored. SQLite users
209    /// are usually better served by [`DbkitConfig::from_url`].
210    pub fn build(self) -> DbkitConfig {
211        let url = match self.backend {
212            Backend::Sqlite => format!("sqlite://{}", self.database),
213            backend => {
214                // Percent-encode credentials: a raw `?`, `@`, `:`, `/`, `#`, etc.
215                // in a user/password would otherwise corrupt the URL (e.g. a `?`
216                // truncates the authority, making the password look like a port).
217                use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
218                let enc = |s: &str| utf8_percent_encode(s, NON_ALPHANUMERIC).to_string();
219                let auth = match (&self.user, &self.password) {
220                    (Some(u), Some(p)) => format!("{}:{}@", enc(u), enc(p)),
221                    (Some(u), None) => format!("{}@", enc(u)),
222                    _ => String::new(),
223                };
224
225                let port = self.port.unwrap_or_else(|| backend.default_port());
226
227                // Each driver validates its own parameter: sqlx-postgres wants
228                // `sslmode=disable|prefer|require`, sqlx-mysql wants
229                // `ssl-mode=DISABLED|PREFERRED|REQUIRED` (it rejects the
230                // Postgres spellings at connect time). Always written out,
231                // since the drivers default to *prefer*, not `Disable`.
232                let ssl_param = match (backend, self.ssl_mode) {
233                    (Backend::MySql, SslMode::Disable) => "?ssl-mode=DISABLED",
234                    (Backend::MySql, SslMode::Prefer) => "?ssl-mode=PREFERRED",
235                    (Backend::MySql, SslMode::Require) => "?ssl-mode=REQUIRED",
236                    (_, SslMode::Disable) => "?sslmode=disable",
237                    (_, SslMode::Prefer) => "?sslmode=prefer",
238                    (_, SslMode::Require) => "?sslmode=require",
239                };
240
241                format!(
242                    "{}://{}{}:{}/{}{}",
243                    backend.scheme(),
244                    auth,
245                    self.host,
246                    port,
247                    self.database,
248                    ssl_param
249                )
250            }
251        };
252
253        DbkitConfig {
254            url,
255            pool_size: self.pool_size,
256            connect_timeout_secs: self.connect_timeout_secs,
257            idle_timeout_secs: self.idle_timeout_secs,
258            auto_create_db: self.auto_create_db,
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_from_url() {
269        let config = DbkitConfig::from_url("postgres://localhost/mydb");
270        assert_eq!(config.url, "postgres://localhost/mydb");
271        assert_eq!(config.pool_size, 16);
272        assert!(config.auto_create_db);
273    }
274
275    #[test]
276    fn test_builder_full() {
277        let config = DbkitConfig::builder()
278            .host("db.example.com")
279            .port(5433)
280            .database("myapp")
281            .user("admin")
282            .password("secret")
283            .pool_size(32)
284            .connect_timeout_secs(10)
285            .ssl_mode(SslMode::Require)
286            .build();
287
288        assert_eq!(
289            config.url,
290            "postgres://admin:secret@db.example.com:5433/myapp?sslmode=require"
291        );
292        assert_eq!(config.pool_size, 32);
293        assert_eq!(config.connect_timeout_secs, 10);
294    }
295
296    #[test]
297    fn test_builder_minimal() {
298        let config = DbkitConfig::builder().database("test").build();
299        assert_eq!(config.url, "postgres://localhost:5432/test?sslmode=disable");
300    }
301
302    #[test]
303    fn test_builder_percent_encodes_credentials() {
304        // `?`/`!` in the password must be encoded or they corrupt the URL.
305        let config = DbkitConfig::builder()
306            .host("localhost")
307            .port(5432)
308            .user("postgres")
309            .password("LexLuthern246!!??")
310            .database("sports_ai_baseball")
311            .build();
312        assert_eq!(
313            config.url,
314            "postgres://postgres:LexLuthern246%21%21%3F%3F@localhost:5432/sports_ai_baseball?sslmode=disable"
315        );
316    }
317
318    #[test]
319    fn test_builder_user_no_password() {
320        let config = DbkitConfig::builder()
321            .user("readonly")
322            .database("prod")
323            .build();
324        assert_eq!(config.url, "postgres://readonly@localhost:5432/prod?sslmode=disable");
325    }
326
327    #[test]
328    fn test_builder_mysql_default_port() {
329        let config = DbkitConfig::builder()
330            .backend(Backend::MySql)
331            .user("root")
332            .database("app")
333            .build();
334        assert_eq!(config.url, "mysql://root@localhost:3306/app?ssl-mode=DISABLED");
335    }
336
337    #[test]
338    fn test_builder_mysql_ssl_mode_spelling() {
339        // sqlx-mysql rejects the Postgres spellings (`sslmode=require`) at
340        // connect time; MySQL URLs must use `ssl-mode=REQUIRED` etc.
341        let config = DbkitConfig::builder()
342            .backend(Backend::MySql)
343            .database("app")
344            .ssl_mode(SslMode::Require)
345            .build();
346        assert_eq!(config.url, "mysql://localhost:3306/app?ssl-mode=REQUIRED");
347    }
348
349    #[test]
350    fn test_builder_sqlite() {
351        let config = DbkitConfig::builder()
352            .backend(Backend::Sqlite)
353            .database("data/app.db")
354            .build();
355        assert_eq!(config.url, "sqlite://data/app.db");
356    }
357
358    #[test]
359    fn test_backend_from_url() {
360        assert_eq!(Backend::from_url("postgres://x/y").unwrap(), Backend::Postgres);
361        assert_eq!(Backend::from_url("postgresql://x/y").unwrap(), Backend::Postgres);
362        assert_eq!(Backend::from_url("mysql://x/y").unwrap(), Backend::MySql);
363        assert_eq!(Backend::from_url("sqlite://f.db").unwrap(), Backend::Sqlite);
364        assert!(Backend::from_url("oracle://x/y").is_err());
365    }
366}