claw-core 0.1.2

Embedded local database engine for ClawDB — an agent-native cognitive database
Documentation
//! Configuration for claw-core.
//!
//! [`ClawConfig`] holds all tunable parameters for a [`crate::engine::ClawEngine`]
//! instance. Use [`ClawConfig::builder()`] to obtain a [`ClawConfigBuilder`]
//! and construct a validated configuration with the builder pattern.
//!
//! # Example
//!
//! ```rust,no_run
//! use claw_core::config::{ClawConfig, JournalMode};
//!
//! let config = ClawConfig::builder()
//!     .max_connections(5)
//!     .wal_enabled(true)
//!     .cache_size_mb(128)
//!     .journal_mode(JournalMode::WAL)
//!     .build()
//!     .expect("valid configuration");
//! ```

use std::path::PathBuf;

use serde::{Deserialize, Serialize};
#[cfg(feature = "encryption")]
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::error::{ClawError, ClawResult};

/// SQLite journal mode.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum JournalMode {
    /// Write-Ahead Logging — recommended for concurrent read/write workloads.
    #[default]
    WAL,
    /// Default rollback journal (delete on commit).
    Delete,
    /// Truncate the journal file instead of deleting it.
    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"),
        }
    }
}

/// Runtime configuration for a claw-core engine instance.
///
/// Construct via [`ClawConfig::builder()`] to ensure all values are validated
/// before use.
#[cfg_attr(feature = "encryption", derive(Zeroize, ZeroizeOnDrop))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClawConfig {
    /// Path to the SQLite database file.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub db_path: PathBuf,

    /// Maximum number of connections in the SQLx connection pool.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub max_connections: u32,

    /// Whether WAL mode is enabled on the SQLite connection.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub wal_enabled: bool,

    /// Maximum in-memory cache size in mebibytes.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub cache_size_mb: usize,

    /// Whether pending migrations are applied automatically on engine startup.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub auto_migrate: bool,

    /// Directory where snapshots are stored. `None` disables snapshot support.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub snapshot_dir: Option<PathBuf>,

    /// SQLite journal mode.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub journal_mode: JournalMode,

    /// Logical workspace identifier used for tracing fields.
    #[cfg_attr(feature = "encryption", zeroize(skip))]
    pub workspace_id: String,

    /// 32-byte AES key used for SQLCipher encryption at rest.
    ///
    /// Only available when the `encryption` feature is enabled.  Requires
    /// linking against a SQLCipher build of SQLite (not the default bundled
    /// libsqlite3).  Set to `None` (the default) to disable encryption.
    #[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 {
    /// Return a new [`ClawConfigBuilder`] pre-populated with [`Default`] values.
    pub fn builder() -> ClawConfigBuilder {
        ClawConfigBuilder::default()
    }
}

/// Builder for [`ClawConfig`].
///
/// Obtain an instance via [`ClawConfig::builder()`]. Every setter is chainable
/// and the final [`ClawConfigBuilder::build()`] call validates the collected
/// values before returning a [`ClawConfig`].
#[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 {
    /// Set the path to the SQLite database file.
    pub fn db_path(mut self, path: impl Into<PathBuf>) -> Self {
        self.db_path = Some(path.into());
        self
    }

    /// Set the maximum number of connections in the connection pool.
    pub fn max_connections(mut self, n: u32) -> Self {
        self.max_connections = n;
        self
    }

    /// Enable or disable WAL mode.
    pub fn wal_enabled(mut self, enabled: bool) -> Self {
        self.wal_enabled = enabled;
        self
    }

    /// Set the maximum in-memory cache size in mebibytes.
    pub fn cache_size_mb(mut self, mb: usize) -> Self {
        self.cache_size_mb = mb;
        self
    }

    /// Enable or disable automatic migration on engine startup.
    pub fn auto_migrate(mut self, enabled: bool) -> Self {
        self.auto_migrate = enabled;
        self
    }

    /// Set the directory used to store snapshots.
    pub fn snapshot_dir(mut self, dir: impl Into<PathBuf>) -> Self {
        self.snapshot_dir = Some(dir.into());
        self
    }

    /// Set the SQLite journal mode.
    pub fn journal_mode(mut self, mode: JournalMode) -> Self {
        self.journal_mode = mode;
        self
    }

    /// Set the logical workspace identifier for tracing instrumentation.
    pub fn workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
        self.workspace_id = workspace_id.into();
        self
    }

    /// Set the 32-byte encryption key for SQLCipher at-rest encryption.
    ///
    /// Only available with the `encryption` feature.  Requires a SQLCipher
    /// build of SQLite.
    #[cfg(feature = "encryption")]
    pub fn encryption_key(mut self, key: [u8; 32]) -> Self {
        self.encryption_key = Some(key);
        self
    }

    /// Validate the collected values and return a [`ClawConfig`].
    ///
    /// # Errors
    ///
    /// Returns [`ClawError::Config`] if:
    /// - `db_path` is not set, or its parent directory does not exist and
    ///   cannot be created.
    /// - `max_connections` is `0`.
    /// - `cache_size_mb` is `0`.
    pub fn build(self) -> ClawResult<ClawConfig> {
        // --- db_path ---
        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()
                    ))
                })?;
            }
        }

        // --- max_connections ---
        if self.max_connections < 1 {
            return Err(ClawError::Config(
                "max_connections must be >= 1".to_string(),
            ));
        }

        // --- cache_size_mb ---
        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);
    }
}