Files
optimclaw/src/settings.rs
T
e843c18141 feat: add libSQL/Turso embedded database backend (#47)
* feat: add libSQL/Turso database backend with full feature parity

Introduce a Database trait abstraction (~60 async methods) enabling
compile-time backend selection between PostgreSQL and libSQL/Turso.
Convert all modules from concrete Store to Arc<dyn Database>, add
LibSqlSecretsStore and LibSqlWasmToolStore implementations, wire
libsql stores throughout CLI and main entry points, and make the
setup wizard backend-agnostic.

Key changes:
- src/db/: Database trait, PostgresDatabase adapter, LibSqlBackend
  with native SQLite-dialect SQL, and idempotent migration system
- src/secrets/store.rs: LibSqlSecretsStore (all 8 trait methods)
- src/tools/wasm/storage.rs: LibSqlWasmToolStore (all 7 trait methods)
- src/main.rs, cli/tool.rs, cli/mcp.rs: backend-conditional wiring
- src/setup/channels.rs: SecretsContext uses Arc<dyn SecretsStore>
- Feature-gate postgres-only tests and examples

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: enable onboarding wizard for libSQL builds

Refactor the setup wizard to work with both postgres and libsql feature
flags. Previously the wizard was gated behind #[cfg(feature = "postgres")]
only, so libsql-only builds would print an error on `ironclaw onboard`.

- Add libsql fields to Settings (database_backend, libsql_path, libsql_url)
- Split wizard database/migration/secrets methods into feature-gated variants
- Add step_database_libsql() with local path and Turso remote replica prompts
- Update setup/mod.rs and main.rs feature gates to any(postgres, libsql)
- Extend check_onboard_needed() to detect libsql database presence

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for libSQL backend

- P0: Switch libsql_backend to connection-per-operation pattern to fix
  shared Connection concurrency issue across tokio tasks
- P0: Wrap secrets store INSERT+SELECT in transaction to fix TOCTOU race
- P0: Document encryption-at-rest limitations and json_patch divergence
- P1: Fix get_opt_text removing .filter(|s| !s.is_empty()) that conflated
  empty strings with NULL
- P1: Replace datetime('now') with fmt_ts(&Utc::now()) for consistent
  RFC 3339 timestamps across all queries
- P2: Use explicit _rowid column in FTS5 triggers and joins for stability
  across VACUUM operations
- P2: Add tracing::warn when embedding provided but vector search disabled
  in hybrid_search
- Extract shared connect_from_config() helper to deduplicate DB connection
  logic across main.rs, cli/config.rs, and cli/mcp.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add missing JobContext fields and resolve fmt/clippy warnings

Add total_tokens_used and max_tokens fields to JobContext in
libsql_backend.rs, apply cargo fmt, and fix clippy warnings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: review fixes for libSQL backend (shared connections, panics, indexes)

- Replace .expect() with proper error propagation in 3 call sites
- Share Arc<Database> between backend and stores instead of single Connection
- Add connect-per-operation pattern to LibSqlSecretsStore and LibSqlWasmToolStore
- Wrap store() INSERT + SELECT-back in a transaction
- Add ~22 missing indexes for parity with PostgreSQL schema
- Add 18 leak_detection_patterns seed rows matching PostgreSQL V2 migration
- Fix super:: import to use crate:: style
- Gate mask_password_in_url behind #[cfg(feature = "postgres")]
- Rewrite secrets store init with or_else chain for runtime backend selection

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Resolve clippy lints (collapsible_if, too_many_arguments)

Collapse nested if blocks into let_chains to satisfy clippy's
collapsible_if lint (CI uses -D warnings). Suppress too_many_arguments
on libsql_row_to_tool_at since refactoring the positional index
pattern would be a larger change.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-02-14 02:05:05 +00:00

916 lines
27 KiB
Rust

//! User settings persistence.
//!
//! Stores user preferences in ~/.ironclaw/settings.json.
//! Settings are loaded with env var > settings.json > default priority.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// User settings persisted to disk.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
/// Whether onboarding wizard has been completed.
#[serde(default, alias = "setup_completed")]
pub onboard_completed: bool,
// === Step 1: Database ===
/// Database backend: "postgres" or "libsql".
#[serde(default)]
pub database_backend: Option<String>,
/// Database connection URL (postgres://...).
#[serde(default)]
pub database_url: Option<String>,
/// Database pool size.
#[serde(default)]
pub database_pool_size: Option<usize>,
/// Path to local libSQL database file.
#[serde(default)]
pub libsql_path: Option<String>,
/// Turso cloud URL for remote replica sync.
#[serde(default)]
pub libsql_url: Option<String>,
// === Step 2: Security ===
/// Source for the secrets master key.
#[serde(default)]
pub secrets_master_key_source: KeySource,
// === Step 3: NEAR AI Auth ===
// Session stored separately in session.json
// === Step 4: Model Selection ===
/// Currently selected model.
#[serde(default)]
pub selected_model: Option<String>,
// === Step 5: Embeddings ===
/// Embeddings configuration.
#[serde(default)]
pub embeddings: EmbeddingsSettings,
// === Step 6: Channels ===
/// Tunnel configuration for public webhook endpoints.
#[serde(default)]
pub tunnel: TunnelSettings,
/// Channel configuration.
#[serde(default)]
pub channels: ChannelSettings,
// === Step 7: Heartbeat ===
/// Heartbeat configuration.
#[serde(default)]
pub heartbeat: HeartbeatSettings,
// === Advanced Settings (not asked during setup, editable via CLI) ===
/// Agent behavior configuration.
#[serde(default)]
pub agent: AgentSettings,
/// WASM sandbox configuration.
#[serde(default)]
pub wasm: WasmSettings,
/// Docker sandbox configuration.
#[serde(default)]
pub sandbox: SandboxSettings,
/// Safety configuration.
#[serde(default)]
pub safety: SafetySettings,
/// Builder configuration.
#[serde(default)]
pub builder: BuilderSettings,
}
/// Source for the secrets master key.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum KeySource {
/// Auto-generated key stored in OS keychain.
Keychain,
/// User provides via SECRETS_MASTER_KEY env var.
Env,
/// Not configured (secrets features disabled).
#[default]
None,
}
/// Embeddings configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsSettings {
/// Whether embeddings are enabled.
#[serde(default)]
pub enabled: bool,
/// Provider to use: "openai" or "nearai".
#[serde(default = "default_embeddings_provider")]
pub provider: String,
/// Model to use for embeddings.
#[serde(default = "default_embeddings_model")]
pub model: String,
}
fn default_embeddings_provider() -> String {
"nearai".to_string()
}
fn default_embeddings_model() -> String {
"text-embedding-3-small".to_string()
}
impl Default for EmbeddingsSettings {
fn default() -> Self {
Self {
enabled: false,
provider: default_embeddings_provider(),
model: default_embeddings_model(),
}
}
}
/// Tunnel settings for public webhook endpoints.
///
/// The tunnel URL is shared across all channels that need webhooks.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TunnelSettings {
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
#[serde(default)]
pub public_url: Option<String>,
}
/// Channel-specific settings.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChannelSettings {
/// Whether HTTP webhook channel is enabled.
#[serde(default)]
pub http_enabled: bool,
/// HTTP webhook port (if enabled).
#[serde(default)]
pub http_port: Option<u16>,
/// HTTP webhook host.
#[serde(default)]
pub http_host: Option<String>,
/// Telegram owner user ID. When set, the bot only responds to this user.
/// Captured during setup by having the user message the bot.
#[serde(default)]
pub telegram_owner_id: Option<i64>,
/// Enabled WASM channels by name.
/// Channels not in this list but present in the channels directory will still load.
/// This is primarily used by the setup wizard to track which channels were configured.
#[serde(default)]
pub wasm_channels: Vec<String>,
/// Whether WASM channels are enabled.
#[serde(default = "default_true")]
pub wasm_channels_enabled: bool,
/// Directory containing WASM channel modules.
#[serde(default)]
pub wasm_channels_dir: Option<PathBuf>,
}
/// Heartbeat configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatSettings {
/// Whether heartbeat is enabled.
#[serde(default)]
pub enabled: bool,
/// Interval between heartbeat checks in seconds.
#[serde(default = "default_heartbeat_interval")]
pub interval_secs: u64,
/// Channel to notify on heartbeat findings.
#[serde(default)]
pub notify_channel: Option<String>,
/// User ID to notify on heartbeat findings.
#[serde(default)]
pub notify_user: Option<String>,
}
fn default_heartbeat_interval() -> u64 {
1800 // 30 minutes
}
impl Default for HeartbeatSettings {
fn default() -> Self {
Self {
enabled: false,
interval_secs: default_heartbeat_interval(),
notify_channel: None,
notify_user: None,
}
}
}
/// Agent behavior configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSettings {
/// Agent name.
#[serde(default = "default_agent_name")]
pub name: String,
/// Maximum parallel jobs.
#[serde(default = "default_max_parallel_jobs")]
pub max_parallel_jobs: u32,
/// Job timeout in seconds.
#[serde(default = "default_job_timeout")]
pub job_timeout_secs: u64,
/// Stuck job threshold in seconds.
#[serde(default = "default_stuck_threshold")]
pub stuck_threshold_secs: u64,
/// Whether to use planning before tool execution.
#[serde(default = "default_true")]
pub use_planning: bool,
/// Self-repair check interval in seconds.
#[serde(default = "default_repair_interval")]
pub repair_check_interval_secs: u64,
/// Maximum repair attempts.
#[serde(default = "default_max_repair_attempts")]
pub max_repair_attempts: u32,
/// Session idle timeout in seconds (default: 7 days). Sessions inactive
/// longer than this are pruned from memory.
#[serde(default = "default_session_idle_timeout")]
pub session_idle_timeout_secs: u64,
}
fn default_agent_name() -> String {
"ironclaw".to_string()
}
fn default_max_parallel_jobs() -> u32 {
5
}
fn default_job_timeout() -> u64 {
3600 // 1 hour
}
fn default_stuck_threshold() -> u64 {
300 // 5 minutes
}
fn default_repair_interval() -> u64 {
60 // 1 minute
}
fn default_session_idle_timeout() -> u64 {
7 * 24 * 3600 // 7 days
}
fn default_max_repair_attempts() -> u32 {
3
}
fn default_true() -> bool {
true
}
impl Default for AgentSettings {
fn default() -> Self {
Self {
name: default_agent_name(),
max_parallel_jobs: default_max_parallel_jobs(),
job_timeout_secs: default_job_timeout(),
stuck_threshold_secs: default_stuck_threshold(),
use_planning: true,
repair_check_interval_secs: default_repair_interval(),
max_repair_attempts: default_max_repair_attempts(),
session_idle_timeout_secs: default_session_idle_timeout(),
}
}
}
/// WASM sandbox configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmSettings {
/// Whether WASM tool execution is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Directory containing installed WASM tools.
#[serde(default)]
pub tools_dir: Option<PathBuf>,
/// Default memory limit in bytes.
#[serde(default = "default_wasm_memory_limit")]
pub default_memory_limit: u64,
/// Default execution timeout in seconds.
#[serde(default = "default_wasm_timeout")]
pub default_timeout_secs: u64,
/// Default fuel limit for CPU metering.
#[serde(default = "default_wasm_fuel_limit")]
pub default_fuel_limit: u64,
/// Whether to cache compiled modules.
#[serde(default = "default_true")]
pub cache_compiled: bool,
/// Directory for compiled module cache.
#[serde(default)]
pub cache_dir: Option<PathBuf>,
}
fn default_wasm_memory_limit() -> u64 {
10 * 1024 * 1024 // 10 MB
}
fn default_wasm_timeout() -> u64 {
60
}
fn default_wasm_fuel_limit() -> u64 {
10_000_000
}
impl Default for WasmSettings {
fn default() -> Self {
Self {
enabled: true,
tools_dir: None,
default_memory_limit: default_wasm_memory_limit(),
default_timeout_secs: default_wasm_timeout(),
default_fuel_limit: default_wasm_fuel_limit(),
cache_compiled: true,
cache_dir: None,
}
}
}
/// Docker sandbox configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxSettings {
/// Whether the Docker sandbox is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Sandbox policy: "readonly", "workspace_write", or "full_access".
#[serde(default = "default_sandbox_policy")]
pub policy: String,
/// Command timeout in seconds.
#[serde(default = "default_sandbox_timeout")]
pub timeout_secs: u64,
/// Memory limit in megabytes.
#[serde(default = "default_sandbox_memory")]
pub memory_limit_mb: u64,
/// CPU shares (relative weight).
#[serde(default = "default_sandbox_cpu_shares")]
pub cpu_shares: u32,
/// Docker image for the sandbox.
#[serde(default = "default_sandbox_image")]
pub image: String,
/// Whether to auto-pull the image if not found.
#[serde(default = "default_true")]
pub auto_pull_image: bool,
/// Additional domains to allow through the network proxy.
#[serde(default)]
pub extra_allowed_domains: Vec<String>,
}
fn default_sandbox_policy() -> String {
"readonly".to_string()
}
fn default_sandbox_timeout() -> u64 {
120
}
fn default_sandbox_memory() -> u64 {
2048
}
fn default_sandbox_cpu_shares() -> u32 {
1024
}
fn default_sandbox_image() -> String {
"ghcr.io/nearai/sandbox:latest".to_string()
}
impl Default for SandboxSettings {
fn default() -> Self {
Self {
enabled: true,
policy: default_sandbox_policy(),
timeout_secs: default_sandbox_timeout(),
memory_limit_mb: default_sandbox_memory(),
cpu_shares: default_sandbox_cpu_shares(),
image: default_sandbox_image(),
auto_pull_image: true,
extra_allowed_domains: Vec::new(),
}
}
}
/// Safety configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafetySettings {
/// Maximum output length in bytes.
#[serde(default = "default_max_output_length")]
pub max_output_length: usize,
/// Whether injection check is enabled.
#[serde(default = "default_true")]
pub injection_check_enabled: bool,
}
fn default_max_output_length() -> usize {
100_000
}
impl Default for SafetySettings {
fn default() -> Self {
Self {
max_output_length: default_max_output_length(),
injection_check_enabled: true,
}
}
}
/// Builder configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuilderSettings {
/// Whether the software builder tool is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Directory for build artifacts.
#[serde(default)]
pub build_dir: Option<PathBuf>,
/// Maximum iterations for the build loop.
#[serde(default = "default_builder_max_iterations")]
pub max_iterations: u32,
/// Build timeout in seconds.
#[serde(default = "default_builder_timeout")]
pub timeout_secs: u64,
/// Whether to automatically register built WASM tools.
#[serde(default = "default_true")]
pub auto_register: bool,
}
fn default_builder_max_iterations() -> u32 {
20
}
fn default_builder_timeout() -> u64 {
600
}
impl Default for BuilderSettings {
fn default() -> Self {
Self {
enabled: true,
build_dir: None,
max_iterations: default_builder_max_iterations(),
timeout_secs: default_builder_timeout(),
auto_register: true,
}
}
}
impl Settings {
/// Get the default settings file path (~/.ironclaw/settings.json).
pub fn default_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("settings.json")
}
/// Reconstruct Settings from a flat key-value map (as stored in the DB).
///
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
/// Missing keys get their default value.
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
// Start with defaults, then overlay each DB setting
let mut settings = Self::default();
for (key, value) in map {
// Convert the JSONB value to a string for the existing set() method
let value_str = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => "null".to_string(),
other => other.to_string(),
};
if let Err(e) = settings.set(key, &value_str) {
tracing::warn!(
"Failed to apply DB setting '{}' = '{}': {}",
key,
value_str,
e
);
}
}
settings
}
/// Flatten Settings into a key-value map suitable for DB storage.
///
/// Each entry is a (dotted_path, JSONB value) pair.
pub fn to_db_map(&self) -> std::collections::HashMap<String, serde_json::Value> {
let json = match serde_json::to_value(self) {
Ok(v) => v,
Err(_) => return std::collections::HashMap::new(),
};
let mut map = std::collections::HashMap::new();
collect_settings_json(&json, String::new(), &mut map);
map
}
/// Load settings from disk, returning default if not found.
pub fn load() -> Self {
Self::load_from(&Self::default_path())
}
/// Load settings from a specific path.
pub fn load_from(path: &PathBuf) -> Self {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Self::default(),
}
}
/// Save settings to disk.
pub fn save(&self) -> std::io::Result<()> {
self.save_to(&Self::default_path())
}
/// Save settings to a specific path.
pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> {
// Ensure parent directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
std::fs::write(path, json)
}
/// Get the selected model, falling back to the provided default.
pub fn model_or(&self, default: &str) -> String {
self.selected_model
.clone()
.unwrap_or_else(|| default.to_string())
}
/// Set the selected model and save.
pub fn set_model(&mut self, model: &str) -> std::io::Result<()> {
self.selected_model = Some(model.to_string());
self.save()
}
/// Get a setting value by dotted path (e.g., "agent.max_parallel_jobs").
pub fn get(&self, path: &str) -> Option<String> {
let json = serde_json::to_value(self).ok()?;
let mut current = &json;
for part in path.split('.') {
current = current.get(part)?;
}
match current {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Null => Some("null".to_string()),
serde_json::Value::Array(arr) => Some(serde_json::to_string(arr).unwrap_or_default()),
serde_json::Value::Object(obj) => Some(serde_json::to_string(obj).unwrap_or_default()),
}
}
/// Set a setting value by dotted path.
///
/// Returns error if path is invalid or value cannot be parsed.
pub fn set(&mut self, path: &str, value: &str) -> Result<(), String> {
let mut json = serde_json::to_value(&self)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() {
return Err("Empty path".to_string());
}
// Navigate to parent and set the final key
let mut current = &mut json;
for part in &parts[..parts.len() - 1] {
current = current
.get_mut(*part)
.ok_or_else(|| format!("Path not found: {}", path))?;
}
let final_key = parts.last().unwrap();
let obj = current
.as_object_mut()
.ok_or_else(|| format!("Parent is not an object: {}", path))?;
// Try to infer the type from the existing value
let new_value = if let Some(existing) = obj.get(*final_key) {
match existing {
serde_json::Value::Bool(_) => {
let b = value
.parse::<bool>()
.map_err(|_| format!("Expected boolean for {}, got '{}'", path, value))?;
serde_json::Value::Bool(b)
}
serde_json::Value::Number(n) => {
if n.is_u64() {
let n = value.parse::<u64>().map_err(|_| {
format!("Expected integer for {}, got '{}'", path, value)
})?;
serde_json::Value::Number(n.into())
} else if n.is_i64() {
let n = value.parse::<i64>().map_err(|_| {
format!("Expected integer for {}, got '{}'", path, value)
})?;
serde_json::Value::Number(n.into())
} else {
let n = value.parse::<f64>().map_err(|_| {
format!("Expected number for {}, got '{}'", path, value)
})?;
serde_json::Number::from_f64(n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::String(value.to_string()))
}
}
serde_json::Value::Null => {
// Could be Option<T>, try to parse as JSON or use string
serde_json::from_str(value)
.unwrap_or(serde_json::Value::String(value.to_string()))
}
serde_json::Value::Array(_) => serde_json::from_str(value)
.map_err(|e| format!("Invalid JSON array for {}: {}", path, e))?,
serde_json::Value::Object(_) => serde_json::from_str(value)
.map_err(|e| format!("Invalid JSON object for {}: {}", path, e))?,
serde_json::Value::String(_) => serde_json::Value::String(value.to_string()),
}
} else {
// Key doesn't exist, try to parse as JSON or use string
serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()))
};
obj.insert((*final_key).to_string(), new_value);
// Deserialize back to Settings
*self =
serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?;
Ok(())
}
/// Reset a setting to its default value.
pub fn reset(&mut self, path: &str) -> Result<(), String> {
let default = Self::default();
let default_value = default
.get(path)
.ok_or_else(|| format!("Unknown setting: {}", path))?;
self.set(path, &default_value)
}
/// List all settings as (path, value) pairs.
pub fn list(&self) -> Vec<(String, String)> {
let json = match serde_json::to_value(self) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut results = Vec::new();
collect_settings(&json, String::new(), &mut results);
results.sort_by(|a, b| a.0.cmp(&b.0));
results
}
}
/// Recursively collect settings paths with their JSON values (for DB storage).
fn collect_settings_json(
value: &serde_json::Value,
prefix: String,
results: &mut std::collections::HashMap<String, serde_json::Value>,
) {
match value {
serde_json::Value::Object(obj) => {
for (key, val) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
collect_settings_json(val, path, results);
}
}
other => {
results.insert(prefix, other.clone());
}
}
}
/// Recursively collect settings paths and values.
fn collect_settings(
value: &serde_json::Value,
prefix: String,
results: &mut Vec<(String, String)>,
) {
match value {
serde_json::Value::Object(obj) => {
for (key, val) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
collect_settings(val, path, results);
}
}
serde_json::Value::Array(arr) => {
let display = serde_json::to_string(arr).unwrap_or_default();
results.push((prefix, display));
}
serde_json::Value::String(s) => {
results.push((prefix, s.clone()));
}
serde_json::Value::Number(n) => {
results.push((prefix, n.to_string()));
}
serde_json::Value::Bool(b) => {
results.push((prefix, b.to_string()));
}
serde_json::Value::Null => {
results.push((prefix, "null".to_string()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_settings_save_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
let settings = Settings {
selected_model: Some("claude-3-5-sonnet-20241022".to_string()),
..Default::default()
};
settings.save_to(&path).unwrap();
let loaded = Settings::load_from(&path);
assert_eq!(
loaded.selected_model,
Some("claude-3-5-sonnet-20241022".to_string())
);
}
#[test]
fn test_model_or_default() {
let settings = Settings::default();
assert_eq!(
settings.model_or("default-model"),
"default-model".to_string()
);
let settings = Settings {
selected_model: Some("my-model".to_string()),
..Default::default()
};
assert_eq!(settings.model_or("default-model"), "my-model".to_string());
}
#[test]
fn test_get_setting() {
let settings = Settings::default();
assert_eq!(settings.get("agent.name"), Some("ironclaw".to_string()));
assert_eq!(
settings.get("agent.max_parallel_jobs"),
Some("5".to_string())
);
assert_eq!(settings.get("heartbeat.enabled"), Some("false".to_string()));
assert_eq!(settings.get("nonexistent"), None);
}
#[test]
fn test_set_setting() {
let mut settings = Settings::default();
settings.set("agent.name", "mybot").unwrap();
assert_eq!(settings.agent.name, "mybot");
settings.set("agent.max_parallel_jobs", "10").unwrap();
assert_eq!(settings.agent.max_parallel_jobs, 10);
settings.set("heartbeat.enabled", "true").unwrap();
assert!(settings.heartbeat.enabled);
}
#[test]
fn test_reset_setting() {
let mut settings = Settings::default();
settings.agent.name = "custom".to_string();
settings.reset("agent.name").unwrap();
assert_eq!(settings.agent.name, "ironclaw");
}
#[test]
fn test_list_settings() {
let settings = Settings::default();
let list = settings.list();
// Check some expected entries
assert!(list.iter().any(|(k, _)| k == "agent.name"));
assert!(list.iter().any(|(k, _)| k == "heartbeat.enabled"));
assert!(list.iter().any(|(k, _)| k == "onboard_completed"));
}
#[test]
fn test_key_source_serialization() {
let settings = Settings {
secrets_master_key_source: KeySource::Keychain,
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"keychain\""));
let loaded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
}
#[test]
fn test_embeddings_defaults() {
let settings = Settings::default();
assert!(!settings.embeddings.enabled);
assert_eq!(settings.embeddings.provider, "nearai");
assert_eq!(settings.embeddings.model, "text-embedding-3-small");
}
#[test]
fn test_telegram_owner_id_round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
let mut settings = Settings::default();
settings.channels.telegram_owner_id = Some(123456789);
settings.save_to(&path).unwrap();
let loaded = Settings::load_from(&path);
assert_eq!(loaded.channels.telegram_owner_id, Some(123456789));
}
#[test]
fn test_telegram_owner_id_default_none() {
let settings = Settings::default();
assert_eq!(settings.channels.telegram_owner_id, None);
}
#[test]
fn test_telegram_owner_id_via_set() {
let mut settings = Settings::default();
settings
.set("channels.telegram_owner_id", "987654321")
.unwrap();
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
}
}