use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[cfg(feature = "encryption")]
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::error::{ClawError, ClawResult};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum JournalMode {
#[default]
WAL,
Delete,
Truncate,
}
impl std::fmt::Display for JournalMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JournalMode::WAL => write!(f, "WAL"),
JournalMode::Delete => write!(f, "DELETE"),
JournalMode::Truncate => write!(f, "TRUNCATE"),
}
}
}
#[cfg_attr(feature = "encryption", derive(Zeroize, ZeroizeOnDrop))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClawConfig {
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub db_path: PathBuf,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub max_connections: u32,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub wal_enabled: bool,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub cache_size_mb: usize,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub auto_migrate: bool,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub snapshot_dir: Option<PathBuf>,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub journal_mode: JournalMode,
#[cfg_attr(feature = "encryption", zeroize(skip))]
pub workspace_id: String,
#[cfg(feature = "encryption")]
pub encryption_key: Option<[u8; 32]>,
}
impl Default for ClawConfig {
fn default() -> Self {
let db_path = dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("clawdb")
.join("claw.db");
ClawConfig {
db_path,
max_connections: 10,
wal_enabled: true,
cache_size_mb: 64,
auto_migrate: true,
snapshot_dir: None,
journal_mode: JournalMode::WAL,
workspace_id: "default".to_string(),
#[cfg(feature = "encryption")]
encryption_key: None,
}
}
}
impl ClawConfig {
pub fn builder() -> ClawConfigBuilder {
ClawConfigBuilder::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClawConfigBuilder {
db_path: Option<PathBuf>,
max_connections: u32,
wal_enabled: bool,
cache_size_mb: usize,
auto_migrate: bool,
snapshot_dir: Option<PathBuf>,
journal_mode: JournalMode,
workspace_id: String,
#[cfg(feature = "encryption")]
encryption_key: Option<[u8; 32]>,
}
impl Default for ClawConfigBuilder {
fn default() -> Self {
let defaults = ClawConfig::default();
ClawConfigBuilder {
db_path: Some(defaults.db_path.clone()),
max_connections: defaults.max_connections,
wal_enabled: defaults.wal_enabled,
cache_size_mb: defaults.cache_size_mb,
auto_migrate: defaults.auto_migrate,
snapshot_dir: defaults.snapshot_dir.clone(),
journal_mode: defaults.journal_mode.clone(),
workspace_id: defaults.workspace_id.clone(),
#[cfg(feature = "encryption")]
encryption_key: None,
}
}
}
impl ClawConfigBuilder {
pub fn db_path(mut self, path: impl Into<PathBuf>) -> Self {
self.db_path = Some(path.into());
self
}
pub fn max_connections(mut self, n: u32) -> Self {
self.max_connections = n;
self
}
pub fn wal_enabled(mut self, enabled: bool) -> Self {
self.wal_enabled = enabled;
self
}
pub fn cache_size_mb(mut self, mb: usize) -> Self {
self.cache_size_mb = mb;
self
}
pub fn auto_migrate(mut self, enabled: bool) -> Self {
self.auto_migrate = enabled;
self
}
pub fn snapshot_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.snapshot_dir = Some(dir.into());
self
}
pub fn journal_mode(mut self, mode: JournalMode) -> Self {
self.journal_mode = mode;
self
}
pub fn workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
self.workspace_id = workspace_id.into();
self
}
#[cfg(feature = "encryption")]
pub fn encryption_key(mut self, key: [u8; 32]) -> Self {
self.encryption_key = Some(key);
self
}
pub fn build(self) -> ClawResult<ClawConfig> {
let db_path = self
.db_path
.ok_or_else(|| ClawError::Config("db_path must be set".to_string()))?;
if let Some(parent) = db_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
ClawError::Config(format!(
"cannot create db_path parent directory '{}': {e}",
parent.display()
))
})?;
}
}
if self.max_connections < 1 {
return Err(ClawError::Config(
"max_connections must be >= 1".to_string(),
));
}
if self.cache_size_mb < 1 {
return Err(ClawError::Config("cache_size_mb must be >= 1".to_string()));
}
Ok(ClawConfig {
db_path,
max_connections: self.max_connections,
wal_enabled: self.wal_enabled,
cache_size_mb: self.cache_size_mb,
auto_migrate: self.auto_migrate,
snapshot_dir: self.snapshot_dir,
journal_mode: self.journal_mode,
workspace_id: self.workspace_id,
#[cfg(feature = "encryption")]
encryption_key: self.encryption_key,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
let config = ClawConfig::default();
assert_eq!(config.max_connections, 10);
assert!(config.wal_enabled);
assert_eq!(config.cache_size_mb, 64);
assert!(config.auto_migrate);
assert_eq!(config.journal_mode, JournalMode::WAL);
assert_eq!(config.workspace_id, "default");
assert!(config.snapshot_dir.is_none());
}
#[test]
fn builder_with_temp_path_succeeds() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let config = ClawConfig::builder()
.db_path(db_path.clone())
.max_connections(5)
.cache_size_mb(32)
.build()
.expect("should succeed");
assert_eq!(config.db_path, db_path);
assert_eq!(config.max_connections, 5);
assert_eq!(config.cache_size_mb, 32);
}
#[test]
fn builder_creates_missing_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("nested").join("deep").join("claw.db");
let config = ClawConfig::builder()
.db_path(db_path.clone())
.build()
.expect("should create parent dirs");
assert!(db_path.parent().unwrap().exists());
assert_eq!(config.db_path, db_path);
}
#[test]
fn builder_rejects_zero_max_connections() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("claw.db");
let err = ClawConfig::builder()
.db_path(db_path)
.max_connections(0)
.build()
.unwrap_err();
assert!(matches!(err, ClawError::Config(_)));
assert!(err.to_string().contains("max_connections"));
}
#[test]
fn builder_rejects_zero_cache_size() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("claw.db");
let err = ClawConfig::builder()
.db_path(db_path)
.cache_size_mb(0)
.build()
.unwrap_err();
assert!(matches!(err, ClawError::Config(_)));
assert!(err.to_string().contains("cache_size_mb"));
}
#[test]
fn journal_mode_display() {
assert_eq!(JournalMode::WAL.to_string(), "WAL");
assert_eq!(JournalMode::Delete.to_string(), "DELETE");
assert_eq!(JournalMode::Truncate.to_string(), "TRUNCATE");
}
#[test]
fn builder_chainable_setters() {
let dir = tempfile::tempdir().unwrap();
let snap_dir = dir.path().join("snapshots");
let db_path = dir.path().join("claw.db");
let config = ClawConfig::builder()
.db_path(db_path)
.max_connections(20)
.wal_enabled(false)
.cache_size_mb(256)
.auto_migrate(false)
.snapshot_dir(snap_dir.clone())
.journal_mode(JournalMode::Truncate)
.workspace_id("ws-a")
.build()
.unwrap();
assert_eq!(config.max_connections, 20);
assert!(!config.wal_enabled);
assert_eq!(config.cache_size_mb, 256);
assert!(!config.auto_migrate);
assert_eq!(config.snapshot_dir, Some(snap_dir));
assert_eq!(config.journal_mode, JournalMode::Truncate);
assert_eq!(config.workspace_id, "ws-a");
}
#[test]
fn config_serde_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("claw.db");
let config = ClawConfig::builder().db_path(db_path).build().unwrap();
let json = serde_json::to_string(&config).unwrap();
let restored: ClawConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.db_path, restored.db_path);
assert_eq!(config.max_connections, restored.max_connections);
assert_eq!(config.journal_mode, restored.journal_mode);
assert_eq!(config.workspace_id, restored.workspace_id);
}
}