Skip to main content

claw_core/
config.rs

1//! Configuration for claw-core.
2//!
3//! [`ClawConfig`] holds all tunable parameters for a [`crate::engine::ClawEngine`]
4//! instance. Use [`ClawConfig::builder()`] to obtain a [`ClawConfigBuilder`]
5//! and construct a validated configuration with the builder pattern.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use claw_core::config::{ClawConfig, JournalMode};
11//!
12//! let config = ClawConfig::builder()
13//!     .max_connections(5)
14//!     .wal_enabled(true)
15//!     .cache_size_mb(128)
16//!     .journal_mode(JournalMode::WAL)
17//!     .build()
18//!     .expect("valid configuration");
19//! ```
20
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::{ClawError, ClawResult};
26
27/// SQLite journal mode.
28#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
29pub enum JournalMode {
30    /// Write-Ahead Logging — recommended for concurrent read/write workloads.
31    #[default]
32    WAL,
33    /// Default rollback journal (delete on commit).
34    Delete,
35    /// Truncate the journal file instead of deleting it.
36    Truncate,
37}
38
39impl std::fmt::Display for JournalMode {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            JournalMode::WAL => write!(f, "WAL"),
43            JournalMode::Delete => write!(f, "DELETE"),
44            JournalMode::Truncate => write!(f, "TRUNCATE"),
45        }
46    }
47}
48
49/// Runtime configuration for a claw-core engine instance.
50///
51/// Construct via [`ClawConfig::builder()`] to ensure all values are validated
52/// before use.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ClawConfig {
55    /// Path to the SQLite database file.
56    pub db_path: PathBuf,
57
58    /// Maximum number of connections in the SQLx connection pool.
59    pub max_connections: u32,
60
61    /// Whether WAL mode is enabled on the SQLite connection.
62    pub wal_enabled: bool,
63
64    /// Maximum in-memory cache size in mebibytes.
65    pub cache_size_mb: usize,
66
67    /// Whether pending migrations are applied automatically on engine startup.
68    pub auto_migrate: bool,
69
70    /// Directory where snapshots are stored. `None` disables snapshot support.
71    pub snapshot_dir: Option<PathBuf>,
72
73    /// SQLite journal mode.
74    pub journal_mode: JournalMode,
75
76    /// 32-byte AES key used for SQLCipher encryption at rest.
77    ///
78    /// Only available when the `encryption` feature is enabled.  Requires
79    /// linking against a SQLCipher build of SQLite (not the default bundled
80    /// libsqlite3).  Set to `None` (the default) to disable encryption.
81    #[cfg(feature = "encryption")]
82    pub encryption_key: Option<[u8; 32]>,
83}
84
85impl Default for ClawConfig {
86    fn default() -> Self {
87        let db_path = dirs::data_local_dir()
88            .unwrap_or_else(|| PathBuf::from("."))
89            .join("clawdb")
90            .join("claw.db");
91
92        ClawConfig {
93            db_path,
94            max_connections: 10,
95            wal_enabled: true,
96            cache_size_mb: 64,
97            auto_migrate: true,
98            snapshot_dir: None,
99            journal_mode: JournalMode::WAL,
100            #[cfg(feature = "encryption")]
101            encryption_key: None,
102        }
103    }
104}
105
106impl ClawConfig {
107    /// Return a new [`ClawConfigBuilder`] pre-populated with [`Default`] values.
108    pub fn builder() -> ClawConfigBuilder {
109        ClawConfigBuilder::default()
110    }
111}
112
113/// Builder for [`ClawConfig`].
114///
115/// Obtain an instance via [`ClawConfig::builder()`]. Every setter is chainable
116/// and the final [`ClawConfigBuilder::build()`] call validates the collected
117/// values before returning a [`ClawConfig`].
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ClawConfigBuilder {
120    db_path: Option<PathBuf>,
121    max_connections: u32,
122    wal_enabled: bool,
123    cache_size_mb: usize,
124    auto_migrate: bool,
125    snapshot_dir: Option<PathBuf>,
126    journal_mode: JournalMode,
127    #[cfg(feature = "encryption")]
128    encryption_key: Option<[u8; 32]>,
129}
130
131impl Default for ClawConfigBuilder {
132    fn default() -> Self {
133        let defaults = ClawConfig::default();
134        ClawConfigBuilder {
135            db_path: Some(defaults.db_path),
136            max_connections: defaults.max_connections,
137            wal_enabled: defaults.wal_enabled,
138            cache_size_mb: defaults.cache_size_mb,
139            auto_migrate: defaults.auto_migrate,
140            snapshot_dir: defaults.snapshot_dir,
141            journal_mode: defaults.journal_mode,
142            #[cfg(feature = "encryption")]
143            encryption_key: None,
144        }
145    }
146}
147
148impl ClawConfigBuilder {
149    /// Set the path to the SQLite database file.
150    pub fn db_path(mut self, path: impl Into<PathBuf>) -> Self {
151        self.db_path = Some(path.into());
152        self
153    }
154
155    /// Set the maximum number of connections in the connection pool.
156    pub fn max_connections(mut self, n: u32) -> Self {
157        self.max_connections = n;
158        self
159    }
160
161    /// Enable or disable WAL mode.
162    pub fn wal_enabled(mut self, enabled: bool) -> Self {
163        self.wal_enabled = enabled;
164        self
165    }
166
167    /// Set the maximum in-memory cache size in mebibytes.
168    pub fn cache_size_mb(mut self, mb: usize) -> Self {
169        self.cache_size_mb = mb;
170        self
171    }
172
173    /// Enable or disable automatic migration on engine startup.
174    pub fn auto_migrate(mut self, enabled: bool) -> Self {
175        self.auto_migrate = enabled;
176        self
177    }
178
179    /// Set the directory used to store snapshots.
180    pub fn snapshot_dir(mut self, dir: impl Into<PathBuf>) -> Self {
181        self.snapshot_dir = Some(dir.into());
182        self
183    }
184
185    /// Set the SQLite journal mode.
186    pub fn journal_mode(mut self, mode: JournalMode) -> Self {
187        self.journal_mode = mode;
188        self
189    }
190
191    /// Set the 32-byte encryption key for SQLCipher at-rest encryption.
192    ///
193    /// Only available with the `encryption` feature.  Requires a SQLCipher
194    /// build of SQLite.
195    #[cfg(feature = "encryption")]
196    pub fn encryption_key(mut self, key: [u8; 32]) -> Self {
197        self.encryption_key = Some(key);
198        self
199    }
200
201    /// Validate the collected values and return a [`ClawConfig`].
202    ///
203    /// # Errors
204    ///
205    /// Returns [`ClawError::Config`] if:
206    /// - `db_path` is not set, or its parent directory does not exist and
207    ///   cannot be created.
208    /// - `max_connections` is `0`.
209    /// - `cache_size_mb` is `0`.
210    pub fn build(self) -> ClawResult<ClawConfig> {
211        // --- db_path ---
212        let db_path = self
213            .db_path
214            .ok_or_else(|| ClawError::Config("db_path must be set".to_string()))?;
215
216        if let Some(parent) = db_path.parent() {
217            if !parent.as_os_str().is_empty() && !parent.exists() {
218                std::fs::create_dir_all(parent).map_err(|e| {
219                    ClawError::Config(format!(
220                        "cannot create db_path parent directory '{}': {e}",
221                        parent.display()
222                    ))
223                })?;
224            }
225        }
226
227        // --- max_connections ---
228        if self.max_connections < 1 {
229            return Err(ClawError::Config(
230                "max_connections must be >= 1".to_string(),
231            ));
232        }
233
234        // --- cache_size_mb ---
235        if self.cache_size_mb < 1 {
236            return Err(ClawError::Config("cache_size_mb must be >= 1".to_string()));
237        }
238
239        Ok(ClawConfig {
240            db_path,
241            max_connections: self.max_connections,
242            wal_enabled: self.wal_enabled,
243            cache_size_mb: self.cache_size_mb,
244            auto_migrate: self.auto_migrate,
245            snapshot_dir: self.snapshot_dir,
246            journal_mode: self.journal_mode,
247            #[cfg(feature = "encryption")]
248            encryption_key: self.encryption_key,
249        })
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn default_config_is_valid() {
259        let config = ClawConfig::default();
260        assert_eq!(config.max_connections, 10);
261        assert!(config.wal_enabled);
262        assert_eq!(config.cache_size_mb, 64);
263        assert!(config.auto_migrate);
264        assert_eq!(config.journal_mode, JournalMode::WAL);
265        assert!(config.snapshot_dir.is_none());
266    }
267
268    #[test]
269    fn builder_with_temp_path_succeeds() {
270        let dir = tempfile::tempdir().unwrap();
271        let db_path = dir.path().join("test.db");
272
273        let config = ClawConfig::builder()
274            .db_path(db_path.clone())
275            .max_connections(5)
276            .cache_size_mb(32)
277            .build()
278            .expect("should succeed");
279
280        assert_eq!(config.db_path, db_path);
281        assert_eq!(config.max_connections, 5);
282        assert_eq!(config.cache_size_mb, 32);
283    }
284
285    #[test]
286    fn builder_creates_missing_parent_dir() {
287        let dir = tempfile::tempdir().unwrap();
288        let db_path = dir.path().join("nested").join("deep").join("claw.db");
289
290        let config = ClawConfig::builder()
291            .db_path(db_path.clone())
292            .build()
293            .expect("should create parent dirs");
294
295        assert!(db_path.parent().unwrap().exists());
296        assert_eq!(config.db_path, db_path);
297    }
298
299    #[test]
300    fn builder_rejects_zero_max_connections() {
301        let dir = tempfile::tempdir().unwrap();
302        let db_path = dir.path().join("claw.db");
303
304        let err = ClawConfig::builder()
305            .db_path(db_path)
306            .max_connections(0)
307            .build()
308            .unwrap_err();
309
310        assert!(matches!(err, ClawError::Config(_)));
311        assert!(err.to_string().contains("max_connections"));
312    }
313
314    #[test]
315    fn builder_rejects_zero_cache_size() {
316        let dir = tempfile::tempdir().unwrap();
317        let db_path = dir.path().join("claw.db");
318
319        let err = ClawConfig::builder()
320            .db_path(db_path)
321            .cache_size_mb(0)
322            .build()
323            .unwrap_err();
324
325        assert!(matches!(err, ClawError::Config(_)));
326        assert!(err.to_string().contains("cache_size_mb"));
327    }
328
329    #[test]
330    fn journal_mode_display() {
331        assert_eq!(JournalMode::WAL.to_string(), "WAL");
332        assert_eq!(JournalMode::Delete.to_string(), "DELETE");
333        assert_eq!(JournalMode::Truncate.to_string(), "TRUNCATE");
334    }
335
336    #[test]
337    fn builder_chainable_setters() {
338        let dir = tempfile::tempdir().unwrap();
339        let snap_dir = dir.path().join("snapshots");
340        let db_path = dir.path().join("claw.db");
341
342        let config = ClawConfig::builder()
343            .db_path(db_path)
344            .max_connections(20)
345            .wal_enabled(false)
346            .cache_size_mb(256)
347            .auto_migrate(false)
348            .snapshot_dir(snap_dir.clone())
349            .journal_mode(JournalMode::Truncate)
350            .build()
351            .unwrap();
352
353        assert_eq!(config.max_connections, 20);
354        assert!(!config.wal_enabled);
355        assert_eq!(config.cache_size_mb, 256);
356        assert!(!config.auto_migrate);
357        assert_eq!(config.snapshot_dir, Some(snap_dir));
358        assert_eq!(config.journal_mode, JournalMode::Truncate);
359    }
360
361    #[test]
362    fn config_serde_roundtrip() {
363        let dir = tempfile::tempdir().unwrap();
364        let db_path = dir.path().join("claw.db");
365        let config = ClawConfig::builder().db_path(db_path).build().unwrap();
366
367        let json = serde_json::to_string(&config).unwrap();
368        let restored: ClawConfig = serde_json::from_str(&json).unwrap();
369        assert_eq!(config.db_path, restored.db_path);
370        assert_eq!(config.max_connections, restored.max_connections);
371        assert_eq!(config.journal_mode, restored.journal_mode);
372    }
373}