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