Skip to main content

forge_core/config/
database.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{ForgeError, Result};
4
5/// Database source configuration.
6/// This enum makes invalid states unrepresentable: you either use embedded
7/// PostgreSQL or connect to a remote server, never both or neither.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(tag = "mode", rename_all = "lowercase")]
10pub enum DatabaseSource {
11    /// Connect to an external PostgreSQL instance.
12    Remote {
13        /// PostgreSQL connection URL.
14        url: String,
15    },
16    /// Use embedded PostgreSQL (zero external dependencies).
17    /// Starts a bundled PostgreSQL instance automatically.
18    /// Requires the `embedded-db` feature.
19    Embedded {
20        /// Data directory for embedded PostgreSQL.
21        /// Defaults to `.forge/postgres` in the current directory.
22        #[serde(default)]
23        data_dir: Option<String>,
24    },
25}
26
27impl Default for DatabaseSource {
28    fn default() -> Self {
29        Self::Remote { url: String::new() }
30    }
31}
32
33/// Database configuration.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DatabaseConfig {
36    /// Database source: remote URL or embedded.
37    #[serde(flatten)]
38    pub source: DatabaseSource,
39
40    /// Connection pool size.
41    #[serde(default = "default_pool_size")]
42    pub pool_size: u32,
43
44    /// Pool checkout timeout in seconds.
45    #[serde(default = "default_pool_timeout")]
46    pub pool_timeout_secs: u64,
47
48    /// Statement timeout in seconds.
49    #[serde(default = "default_statement_timeout")]
50    pub statement_timeout_secs: u64,
51
52    /// Read replica URLs for scaling reads.
53    #[serde(default)]
54    pub replica_urls: Vec<String>,
55
56    /// Whether to route read queries to replicas.
57    #[serde(default)]
58    pub read_from_replica: bool,
59
60    /// Connection pool isolation configuration.
61    #[serde(default)]
62    pub pools: PoolsConfig,
63}
64
65impl Default for DatabaseConfig {
66    fn default() -> Self {
67        Self {
68            source: DatabaseSource::default(),
69            pool_size: default_pool_size(),
70            pool_timeout_secs: default_pool_timeout(),
71            statement_timeout_secs: default_statement_timeout(),
72            replica_urls: Vec::new(),
73            read_from_replica: false,
74            pools: PoolsConfig::default(),
75        }
76    }
77}
78
79impl DatabaseConfig {
80    /// Create a config for a remote database.
81    pub fn remote(url: impl Into<String>) -> Self {
82        Self {
83            source: DatabaseSource::Remote { url: url.into() },
84            ..Default::default()
85        }
86    }
87
88    /// Create a config for embedded PostgreSQL.
89    pub fn embedded() -> Self {
90        Self {
91            source: DatabaseSource::Embedded { data_dir: None },
92            ..Default::default()
93        }
94    }
95
96    /// Create a config for embedded PostgreSQL with a custom data directory.
97    pub fn embedded_with_data_dir(data_dir: impl Into<String>) -> Self {
98        Self {
99            source: DatabaseSource::Embedded {
100                data_dir: Some(data_dir.into()),
101            },
102            ..Default::default()
103        }
104    }
105
106    /// Check if this config uses embedded PostgreSQL.
107    pub fn is_embedded(&self) -> bool {
108        matches!(self.source, DatabaseSource::Embedded { .. })
109    }
110
111    /// Get the remote URL if configured.
112    pub fn url(&self) -> Option<&str> {
113        match &self.source {
114            DatabaseSource::Remote { url } => Some(url),
115            DatabaseSource::Embedded { .. } => None,
116        }
117    }
118
119    /// Get the data directory for embedded mode.
120    pub fn data_dir(&self) -> Option<&str> {
121        match &self.source {
122            DatabaseSource::Remote { .. } => None,
123            DatabaseSource::Embedded { data_dir } => data_dir.as_deref(),
124        }
125    }
126
127    /// Validate the database configuration.
128    pub fn validate(&self) -> Result<()> {
129        if let DatabaseSource::Remote { url } = &self.source
130            && url.is_empty()
131        {
132            return Err(ForgeError::Config(
133                "database.url is required when mode = \"remote\". \
134                 Set database.url to a PostgreSQL connection string \
135                 (e.g., \"postgres://user:pass@localhost/mydb\"), \
136                 or use mode = \"embedded\" for zero-dependency development."
137                    .into(),
138            ));
139        }
140        Ok(())
141    }
142}
143
144fn default_pool_size() -> u32 {
145    50
146}
147
148fn default_pool_timeout() -> u64 {
149    30
150}
151
152fn default_statement_timeout() -> u64 {
153    30
154}
155
156/// Pool isolation configuration for different workloads.
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct PoolsConfig {
159    /// Default pool for queries/mutations.
160    #[serde(default)]
161    pub default: Option<PoolConfig>,
162
163    /// Pool for background jobs.
164    #[serde(default)]
165    pub jobs: Option<PoolConfig>,
166
167    /// Pool for observability writes.
168    #[serde(default)]
169    pub observability: Option<PoolConfig>,
170
171    /// Pool for long-running analytics.
172    #[serde(default)]
173    pub analytics: Option<PoolConfig>,
174}
175
176/// Individual pool configuration.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PoolConfig {
179    /// Pool size.
180    pub size: u32,
181
182    /// Checkout timeout in seconds.
183    #[serde(default = "default_pool_timeout")]
184    pub timeout_secs: u64,
185
186    /// Statement timeout in seconds (optional override).
187    pub statement_timeout_secs: Option<u64>,
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_default_database_config() {
197        let config = DatabaseConfig::default();
198        assert_eq!(config.pool_size, 50);
199        assert_eq!(config.pool_timeout_secs, 30);
200        assert!(!config.is_embedded());
201    }
202
203    #[test]
204    fn test_remote_config() {
205        let config = DatabaseConfig::remote("postgres://localhost/test");
206        assert_eq!(config.url(), Some("postgres://localhost/test"));
207        assert!(!config.is_embedded());
208        assert!(config.data_dir().is_none());
209    }
210
211    #[test]
212    fn test_embedded_config() {
213        let config = DatabaseConfig::embedded();
214        assert!(config.is_embedded());
215        assert!(config.url().is_none());
216        assert!(config.data_dir().is_none());
217    }
218
219    #[test]
220    fn test_embedded_with_data_dir() {
221        let config = DatabaseConfig::embedded_with_data_dir("/var/forge/data");
222        assert!(config.is_embedded());
223        assert_eq!(config.data_dir(), Some("/var/forge/data"));
224    }
225
226    #[test]
227    fn test_parse_remote_config() {
228        let toml = r#"
229            mode = "remote"
230            url = "postgres://localhost/test"
231            pool_size = 100
232            replica_urls = ["postgres://replica1/test", "postgres://replica2/test"]
233            read_from_replica = true
234        "#;
235
236        let config: DatabaseConfig = toml::from_str(toml).unwrap();
237        assert_eq!(config.pool_size, 100);
238        assert_eq!(config.url(), Some("postgres://localhost/test"));
239        assert_eq!(config.replica_urls.len(), 2);
240        assert!(config.read_from_replica);
241    }
242
243    #[test]
244    fn test_parse_embedded_config() {
245        let toml = r#"
246            mode = "embedded"
247            data_dir = ".forge/data"
248            pool_size = 20
249        "#;
250
251        let config: DatabaseConfig = toml::from_str(toml).unwrap();
252        assert!(config.is_embedded());
253        assert_eq!(config.data_dir(), Some(".forge/data"));
254        assert_eq!(config.pool_size, 20);
255    }
256
257    #[test]
258    fn test_parse_embedded_no_data_dir() {
259        let toml = r#"
260            mode = "embedded"
261        "#;
262
263        let config: DatabaseConfig = toml::from_str(toml).unwrap();
264        assert!(config.is_embedded());
265        assert!(config.data_dir().is_none());
266    }
267
268    #[test]
269    fn test_serialize_remote() {
270        let config = DatabaseConfig::remote("postgres://localhost/test");
271        let toml_str = toml::to_string(&config).unwrap();
272        assert!(toml_str.contains("mode = \"remote\""));
273        assert!(toml_str.contains("url = \"postgres://localhost/test\""));
274    }
275
276    #[test]
277    fn test_serialize_embedded() {
278        let config = DatabaseConfig::embedded_with_data_dir(".forge/data");
279        let toml_str = toml::to_string(&config).unwrap();
280        assert!(toml_str.contains("mode = \"embedded\""));
281        assert!(toml_str.contains("data_dir = \".forge/data\""));
282    }
283
284    #[test]
285    fn test_validate_remote_with_url() {
286        let config = DatabaseConfig::remote("postgres://localhost/test");
287        assert!(config.validate().is_ok());
288    }
289
290    #[test]
291    fn test_validate_remote_empty_url() {
292        let config = DatabaseConfig::default();
293        let result = config.validate();
294        assert!(result.is_err());
295        let err_msg = result.unwrap_err().to_string();
296        assert!(err_msg.contains("database.url is required"));
297    }
298
299    #[test]
300    fn test_validate_embedded() {
301        let config = DatabaseConfig::embedded();
302        assert!(config.validate().is_ok());
303    }
304}