forge_core/config/
database.rs1use serde::{Deserialize, Serialize};
2
3use crate::error::{ForgeError, Result};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(tag = "mode", rename_all = "lowercase")]
10pub enum DatabaseSource {
11 Remote {
13 url: String,
15 },
16 Embedded {
20 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DatabaseConfig {
36 #[serde(flatten)]
38 pub source: DatabaseSource,
39
40 #[serde(default = "default_pool_size")]
42 pub pool_size: u32,
43
44 #[serde(default = "default_pool_timeout")]
46 pub pool_timeout_secs: u64,
47
48 #[serde(default = "default_statement_timeout")]
50 pub statement_timeout_secs: u64,
51
52 #[serde(default)]
54 pub replica_urls: Vec<String>,
55
56 #[serde(default)]
58 pub read_from_replica: bool,
59
60 #[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 pub fn remote(url: impl Into<String>) -> Self {
82 Self {
83 source: DatabaseSource::Remote { url: url.into() },
84 ..Default::default()
85 }
86 }
87
88 pub fn embedded() -> Self {
90 Self {
91 source: DatabaseSource::Embedded { data_dir: None },
92 ..Default::default()
93 }
94 }
95
96 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 pub fn is_embedded(&self) -> bool {
108 matches!(self.source, DatabaseSource::Embedded { .. })
109 }
110
111 pub fn url(&self) -> Option<&str> {
113 match &self.source {
114 DatabaseSource::Remote { url } => Some(url),
115 DatabaseSource::Embedded { .. } => None,
116 }
117 }
118
119 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 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct PoolsConfig {
159 #[serde(default)]
161 pub default: Option<PoolConfig>,
162
163 #[serde(default)]
165 pub jobs: Option<PoolConfig>,
166
167 #[serde(default)]
169 pub observability: Option<PoolConfig>,
170
171 #[serde(default)]
173 pub analytics: Option<PoolConfig>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PoolConfig {
179 pub size: u32,
181
182 #[serde(default = "default_pool_timeout")]
184 pub timeout_secs: u64,
185
186 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}