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 Postgres connections.
113#[derive(Debug, Clone, Copy, Default)]
114pub enum SslMode {
115    /// No SSL (default — matches current NoTls behavior).
116    #[default]
117    Disable,
118    /// Prefer SSL but allow fallback.
119    Prefer,
120    /// Require SSL.
121    Require,
122}
123
124impl Default for ConfigBuilder {
125    fn default() -> Self {
126        Self {
127            backend: Backend::Postgres,
128            host: "localhost".into(),
129            port: None,
130            database: "postgres".into(),
131            user: None,
132            password: None,
133            pool_size: 16,
134            connect_timeout_secs: 30,
135            idle_timeout_secs: 300,
136            auto_create_db: true,
137            ssl_mode: SslMode::default(),
138        }
139    }
140}
141
142impl ConfigBuilder {
143    /// Select the target backend. Defaults to [`Backend::Postgres`].
144    pub fn backend(mut self, backend: Backend) -> Self {
145        self.backend = backend;
146        self
147    }
148
149    pub fn host(mut self, host: &str) -> Self {
150        self.host = host.to_string();
151        self
152    }
153
154    pub fn port(mut self, port: u16) -> Self {
155        self.port = Some(port);
156        self
157    }
158
159    pub fn database(mut self, database: &str) -> Self {
160        self.database = database.to_string();
161        self
162    }
163
164    pub fn user(mut self, user: &str) -> Self {
165        self.user = Some(user.to_string());
166        self
167    }
168
169    pub fn password(mut self, password: &str) -> Self {
170        self.password = Some(password.to_string());
171        self
172    }
173
174    pub fn pool_size(mut self, size: usize) -> Self {
175        self.pool_size = size;
176        self
177    }
178
179    pub fn connect_timeout_secs(mut self, secs: u64) -> Self {
180        self.connect_timeout_secs = secs;
181        self
182    }
183
184    pub fn idle_timeout_secs(mut self, secs: u64) -> Self {
185        self.idle_timeout_secs = secs;
186        self
187    }
188
189    pub fn auto_create_db(mut self, enabled: bool) -> Self {
190        self.auto_create_db = enabled;
191        self
192    }
193
194    pub fn ssl_mode(mut self, mode: SslMode) -> Self {
195        self.ssl_mode = mode;
196        self
197    }
198
199    /// Build the config, constructing the connection URL from parts.
200    ///
201    /// For [`Backend::Sqlite`] the URL is `sqlite://{database}`, treating
202    /// `database` as a file path; host/port/auth/SSL are ignored. SQLite users
203    /// are usually better served by [`DbkitConfig::from_url`].
204    pub fn build(self) -> DbkitConfig {
205        let url = match self.backend {
206            Backend::Sqlite => format!("sqlite://{}", self.database),
207            backend => {
208                // Percent-encode credentials: a raw `?`, `@`, `:`, `/`, `#`, etc.
209                // in a user/password would otherwise corrupt the URL (e.g. a `?`
210                // truncates the authority, making the password look like a port).
211                use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
212                let enc = |s: &str| utf8_percent_encode(s, NON_ALPHANUMERIC).to_string();
213                let auth = match (&self.user, &self.password) {
214                    (Some(u), Some(p)) => format!("{}:{}@", enc(u), enc(p)),
215                    (Some(u), None) => format!("{}@", enc(u)),
216                    _ => String::new(),
217                };
218
219                let port = self.port.unwrap_or_else(|| backend.default_port());
220
221                let ssl_param = match self.ssl_mode {
222                    SslMode::Disable => "",
223                    SslMode::Prefer => "?sslmode=prefer",
224                    SslMode::Require => "?sslmode=require",
225                };
226
227                format!(
228                    "{}://{}{}:{}/{}{}",
229                    backend.scheme(),
230                    auth,
231                    self.host,
232                    port,
233                    self.database,
234                    ssl_param
235                )
236            }
237        };
238
239        DbkitConfig {
240            url,
241            pool_size: self.pool_size,
242            connect_timeout_secs: self.connect_timeout_secs,
243            idle_timeout_secs: self.idle_timeout_secs,
244            auto_create_db: self.auto_create_db,
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_from_url() {
255        let config = DbkitConfig::from_url("postgres://localhost/mydb");
256        assert_eq!(config.url, "postgres://localhost/mydb");
257        assert_eq!(config.pool_size, 16);
258        assert!(config.auto_create_db);
259    }
260
261    #[test]
262    fn test_builder_full() {
263        let config = DbkitConfig::builder()
264            .host("db.example.com")
265            .port(5433)
266            .database("myapp")
267            .user("admin")
268            .password("secret")
269            .pool_size(32)
270            .connect_timeout_secs(10)
271            .ssl_mode(SslMode::Require)
272            .build();
273
274        assert_eq!(
275            config.url,
276            "postgres://admin:secret@db.example.com:5433/myapp?sslmode=require"
277        );
278        assert_eq!(config.pool_size, 32);
279        assert_eq!(config.connect_timeout_secs, 10);
280    }
281
282    #[test]
283    fn test_builder_minimal() {
284        let config = DbkitConfig::builder().database("test").build();
285        assert_eq!(config.url, "postgres://localhost:5432/test");
286    }
287
288    #[test]
289    fn test_builder_percent_encodes_credentials() {
290        // `?`/`!` in the password must be encoded or they corrupt the URL.
291        let config = DbkitConfig::builder()
292            .host("localhost")
293            .port(5432)
294            .user("postgres")
295            .password("LexLuthern246!!??")
296            .database("sports_ai_baseball")
297            .build();
298        assert_eq!(
299            config.url,
300            "postgres://postgres:LexLuthern246%21%21%3F%3F@localhost:5432/sports_ai_baseball"
301        );
302    }
303
304    #[test]
305    fn test_builder_user_no_password() {
306        let config = DbkitConfig::builder()
307            .user("readonly")
308            .database("prod")
309            .build();
310        assert_eq!(config.url, "postgres://readonly@localhost:5432/prod");
311    }
312
313    #[test]
314    fn test_builder_mysql_default_port() {
315        let config = DbkitConfig::builder()
316            .backend(Backend::MySql)
317            .user("root")
318            .database("app")
319            .build();
320        assert_eq!(config.url, "mysql://root@localhost:3306/app");
321    }
322
323    #[test]
324    fn test_builder_sqlite() {
325        let config = DbkitConfig::builder()
326            .backend(Backend::Sqlite)
327            .database("data/app.db")
328            .build();
329        assert_eq!(config.url, "sqlite://data/app.db");
330    }
331
332    #[test]
333    fn test_backend_from_url() {
334        assert_eq!(Backend::from_url("postgres://x/y").unwrap(), Backend::Postgres);
335        assert_eq!(Backend::from_url("postgresql://x/y").unwrap(), Backend::Postgres);
336        assert_eq!(Backend::from_url("mysql://x/y").unwrap(), Backend::MySql);
337        assert_eq!(Backend::from_url("sqlite://f.db").unwrap(), Backend::Sqlite);
338        assert!(Backend::from_url("oracle://x/y").is_err());
339    }
340}