1use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24#[cfg(feature = "encryption")]
25use zeroize::{Zeroize, ZeroizeOnDrop};
26
27use crate::error::{ClawError, ClawResult};
28
29#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
31pub enum JournalMode {
32 #[default]
34 WAL,
35 Delete,
37 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#[cfg_attr(feature = "encryption", derive(Zeroize, ZeroizeOnDrop))]
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ClawConfig {
58 #[cfg_attr(feature = "encryption", zeroize(skip))]
60 pub db_path: PathBuf,
61
62 #[cfg_attr(feature = "encryption", zeroize(skip))]
64 pub max_connections: u32,
65
66 #[cfg_attr(feature = "encryption", zeroize(skip))]
68 pub wal_enabled: bool,
69
70 #[cfg_attr(feature = "encryption", zeroize(skip))]
72 pub cache_size_mb: usize,
73
74 #[cfg_attr(feature = "encryption", zeroize(skip))]
76 pub auto_migrate: bool,
77
78 #[cfg_attr(feature = "encryption", zeroize(skip))]
80 pub snapshot_dir: Option<PathBuf>,
81
82 #[cfg_attr(feature = "encryption", zeroize(skip))]
84 pub journal_mode: JournalMode,
85
86 #[cfg_attr(feature = "encryption", zeroize(skip))]
88 pub workspace_id: String,
89
90 #[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 pub fn builder() -> ClawConfigBuilder {
124 ClawConfigBuilder::default()
125 }
126}
127
128#[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 pub fn db_path(mut self, path: impl Into<PathBuf>) -> Self {
168 self.db_path = Some(path.into());
169 self
170 }
171
172 pub fn max_connections(mut self, n: u32) -> Self {
174 self.max_connections = n;
175 self
176 }
177
178 pub fn wal_enabled(mut self, enabled: bool) -> Self {
180 self.wal_enabled = enabled;
181 self
182 }
183
184 pub fn cache_size_mb(mut self, mb: usize) -> Self {
186 self.cache_size_mb = mb;
187 self
188 }
189
190 pub fn auto_migrate(mut self, enabled: bool) -> Self {
192 self.auto_migrate = enabled;
193 self
194 }
195
196 pub fn snapshot_dir(mut self, dir: impl Into<PathBuf>) -> Self {
198 self.snapshot_dir = Some(dir.into());
199 self
200 }
201
202 pub fn journal_mode(mut self, mode: JournalMode) -> Self {
204 self.journal_mode = mode;
205 self
206 }
207
208 pub fn workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
210 self.workspace_id = workspace_id.into();
211 self
212 }
213
214 #[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 pub fn build(self) -> ClawResult<ClawConfig> {
234 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 if self.max_connections < 1 {
252 return Err(ClawError::Config(
253 "max_connections must be >= 1".to_string(),
254 ));
255 }
256
257 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}