use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::bootstrap::ironclaw_base_dir;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
#[serde(default, alias = "setup_completed")]
pub onboard_completed: bool,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(default)]
pub database_backend: Option<String>,
#[serde(default)]
pub database_url: Option<String>,
#[serde(default)]
pub database_pool_size: Option<usize>,
#[serde(default)]
pub libsql_path: Option<String>,
#[serde(default)]
pub libsql_url: Option<String>,
#[serde(default)]
pub secrets_master_key_source: KeySource,
#[serde(default, skip_serializing)]
pub secrets_master_key_hex: Option<String>,
#[serde(default)]
pub llm_backend: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub openai_compatible_base_url: Option<String>,
#[serde(default)]
pub bedrock_region: Option<String>,
#[serde(default)]
pub bedrock_cross_region: Option<String>,
#[serde(default)]
pub bedrock_profile: Option<String>,
#[serde(default)]
pub selected_model: Option<String>,
#[serde(default)]
pub embeddings: EmbeddingsSettings,
#[serde(default)]
pub tunnel: TunnelSettings,
#[serde(default)]
pub channels: ChannelSettings,
#[serde(default)]
pub heartbeat: HeartbeatSettings,
#[serde(default, alias = "personal_onboarding_completed")]
pub profile_onboarding_completed: bool,
#[serde(default)]
pub agent: AgentSettings,
#[serde(default)]
pub wasm: WasmSettings,
#[serde(default)]
pub sandbox: SandboxSettings,
#[serde(default)]
pub safety: SafetySettings,
#[serde(default)]
pub builder: BuilderSettings,
#[serde(default)]
pub transcription: Option<TranscriptionSettings>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum KeySource {
Keychain,
Env,
#[default]
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_embeddings_provider")]
pub provider: String,
#[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(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TunnelSettings {
#[serde(default)]
pub public_url: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub cf_token: Option<String>,
#[serde(default)]
pub ngrok_token: Option<String>,
#[serde(default)]
pub ngrok_domain: Option<String>,
#[serde(default)]
pub ts_funnel: bool,
#[serde(default)]
pub ts_hostname: Option<String>,
#[serde(default)]
pub custom_command: Option<String>,
#[serde(default)]
pub custom_health_url: Option<String>,
#[serde(default)]
pub custom_url_pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelSettings {
#[serde(default)]
pub http_enabled: bool,
#[serde(default)]
pub http_port: Option<u16>,
#[serde(default)]
pub http_host: Option<String>,
#[serde(default = "default_true")]
pub gateway_enabled: bool,
#[serde(default)]
pub gateway_host: Option<String>,
#[serde(default)]
pub gateway_port: Option<u16>,
#[serde(default)]
pub gateway_auth_token: Option<String>,
#[serde(default)]
pub gateway_user_id: Option<String>,
#[serde(default = "default_true")]
pub cli_enabled: bool,
#[serde(default)]
pub signal_enabled: bool,
#[serde(default)]
pub signal_http_url: Option<String>,
#[serde(default)]
pub signal_account: Option<String>,
#[serde(default)]
pub signal_allow_from: Option<String>,
#[serde(default)]
pub signal_allow_from_groups: Option<String>,
#[serde(default)]
pub signal_dm_policy: Option<String>,
#[serde(default)]
pub signal_group_policy: Option<String>,
#[serde(default)]
pub signal_group_allow_from: Option<String>,
#[serde(default)]
pub wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
#[serde(default)]
pub wasm_channels: Vec<String>,
#[serde(default = "default_true")]
pub wasm_channels_enabled: bool,
#[serde(default)]
pub wasm_channels_dir: Option<PathBuf>,
}
impl Default for ChannelSettings {
fn default() -> Self {
Self {
http_enabled: false,
http_port: None,
http_host: None,
gateway_enabled: true,
gateway_host: None,
gateway_port: None,
gateway_auth_token: None,
gateway_user_id: None,
cli_enabled: true,
signal_enabled: false,
signal_http_url: None,
signal_account: None,
signal_allow_from: None,
signal_allow_from_groups: None,
signal_dm_policy: None,
signal_group_policy: None,
signal_group_allow_from: None,
wasm_channel_owner_ids: std::collections::HashMap::new(),
wasm_channels: Vec::new(),
wasm_channels_enabled: true,
wasm_channels_dir: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_heartbeat_interval")]
pub interval_secs: u64,
#[serde(default)]
pub notify_channel: Option<String>,
#[serde(default)]
pub notify_user: Option<String>,
#[serde(default)]
pub fire_at: Option<String>,
#[serde(default)]
pub quiet_hours_start: Option<u32>,
#[serde(default)]
pub quiet_hours_end: Option<u32>,
#[serde(default)]
pub timezone: Option<String>,
}
fn default_heartbeat_interval() -> u64 {
1800 }
impl Default for HeartbeatSettings {
fn default() -> Self {
Self {
enabled: false,
interval_secs: default_heartbeat_interval(),
notify_channel: None,
notify_user: None,
fire_at: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSettings {
#[serde(default = "default_agent_name")]
pub name: String,
#[serde(default = "default_max_parallel_jobs")]
pub max_parallel_jobs: u32,
#[serde(default = "default_job_timeout")]
pub job_timeout_secs: u64,
#[serde(default = "default_stuck_threshold")]
pub stuck_threshold_secs: u64,
#[serde(default = "default_true")]
pub use_planning: bool,
#[serde(default = "default_repair_interval")]
pub repair_check_interval_secs: u64,
#[serde(default = "default_max_repair_attempts")]
pub max_repair_attempts: u32,
#[serde(default = "default_session_idle_timeout")]
pub session_idle_timeout_secs: u64,
#[serde(default = "default_max_tool_iterations")]
pub max_tool_iterations: usize,
#[serde(default)]
pub auto_approve_tools: bool,
#[serde(default = "default_timezone")]
pub default_timezone: String,
#[serde(default)]
pub max_tokens_per_job: u64,
}
fn default_agent_name() -> String {
"ironclaw".to_string()
}
fn default_max_parallel_jobs() -> u32 {
5
}
fn default_job_timeout() -> u64 {
3600 }
fn default_stuck_threshold() -> u64 {
300 }
fn default_repair_interval() -> u64 {
60 }
fn default_session_idle_timeout() -> u64 {
7 * 24 * 3600 }
fn default_max_repair_attempts() -> u32 {
3
}
fn default_max_tool_iterations() -> usize {
50
}
fn default_timezone() -> String {
"UTC".to_string()
}
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(),
max_tool_iterations: default_max_tool_iterations(),
auto_approve_tools: false,
default_timezone: default_timezone(),
max_tokens_per_job: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub tools_dir: Option<PathBuf>,
#[serde(default = "default_wasm_memory_limit")]
pub default_memory_limit: u64,
#[serde(default = "default_wasm_timeout")]
pub default_timeout_secs: u64,
#[serde(default = "default_wasm_fuel_limit")]
pub default_fuel_limit: u64,
#[serde(default = "default_true")]
pub cache_compiled: bool,
#[serde(default)]
pub cache_dir: Option<PathBuf>,
}
fn default_wasm_memory_limit() -> u64 {
10 * 1024 * 1024 }
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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_sandbox_policy")]
pub policy: String,
#[serde(default = "default_sandbox_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_sandbox_memory")]
pub memory_limit_mb: u64,
#[serde(default = "default_sandbox_cpu_shares")]
pub cpu_shares: u32,
#[serde(default = "default_sandbox_image")]
pub image: String,
#[serde(default = "default_true")]
pub auto_pull_image: bool,
#[serde(default)]
pub extra_allowed_domains: Vec<String>,
#[serde(default)]
pub claude_code_enabled: bool,
}
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 {
"ironclaw-worker: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(),
claude_code_enabled: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafetySettings {
#[serde(default = "default_max_output_length")]
pub max_output_length: usize,
#[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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuilderSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub build_dir: Option<PathBuf>,
#[serde(default = "default_builder_max_iterations")]
pub max_iterations: u32,
#[serde(default = "default_builder_timeout")]
pub timeout_secs: u64,
#[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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptionSettings {
#[serde(default)]
pub enabled: bool,
}
impl Settings {
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
let mut settings = Self::default();
for (key, value) in map {
if key == "owner_id" {
continue;
}
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 => continue, other => other.to_string(),
};
match settings.set(key, &value_str) {
Ok(()) => {}
Err(e) if e.starts_with("Path not found") => {}
Err(e) => {
tracing::warn!(
"Failed to apply DB setting '{}' = '{}': {}",
key,
value_str,
e
);
}
}
}
settings
}
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.remove("owner_id");
map
}
pub fn default_path() -> std::path::PathBuf {
ironclaw_base_dir().join("settings.json")
}
pub fn load() -> Self {
Self::load_from(&Self::default_path())
}
pub fn load_from(path: &std::path::Path) -> Self {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn default_toml_path() -> PathBuf {
ironclaw_base_dir().join("config.toml")
}
pub fn load_toml(path: &std::path::Path) -> Result<Option<Self>, String> {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("failed to read {}: {}", path.display(), e)),
};
let settings: Self = toml::from_str(&data)
.map_err(|e| format!("invalid TOML in {}: {}", path.display(), e))?;
Ok(Some(settings))
}
pub fn save_toml(&self, path: &std::path::Path) -> Result<(), String> {
let raw = toml::to_string_pretty(self)
.map_err(|e| format!("failed to serialize settings: {}", e))?;
let content = format!(
"# IronClaw configuration file.\n\
#\n\
# Priority: env var > this file > database settings > defaults.\n\
# Uncomment and edit values to override defaults.\n\
# Run `ironclaw config init` to regenerate this file.\n\
#\n\
# Documentation: https://github.com/nearai/ironclaw\n\
\n\
{raw}"
);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
}
std::fs::write(path, content)
.map_err(|e| format!("failed to write {}: {}", path.display(), e))
}
pub fn merge_from(&mut self, other: &Self) {
let default_json = match serde_json::to_value(Self::default()) {
Ok(v) => v,
Err(_) => return,
};
let other_json = match serde_json::to_value(other) {
Ok(v) => v,
Err(_) => return,
};
let mut self_json = match serde_json::to_value(&*self) {
Ok(v) => v,
Err(_) => return,
};
merge_non_default(&mut self_json, &other_json, &default_json);
if let Ok(merged) = serde_json::from_value(self_json) {
*self = merged;
}
}
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()),
}
}
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();
let (final_key, parent_parts) =
parts.split_last().ok_or_else(|| "Empty path".to_string())?;
let mut current = &mut json;
for part in parent_parts {
current = current
.get_mut(*part)
.ok_or_else(|| format!("Path not found: {}", path))?;
}
let obj = current
.as_object_mut()
.ok_or_else(|| format!("Parent is not an object: {}", path))?;
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 => {
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 {
serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()))
};
obj.insert((*final_key).to_string(), new_value);
*self =
serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?;
Ok(())
}
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)
}
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
}
}
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());
}
}
}
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()));
}
}
}
fn merge_non_default(
target: &mut serde_json::Value,
other: &serde_json::Value,
defaults: &serde_json::Value,
) {
match (target, other, defaults) {
(
serde_json::Value::Object(t),
serde_json::Value::Object(o),
serde_json::Value::Object(d),
) => {
for (key, other_val) in o {
let default_val = d.get(key).cloned().unwrap_or(serde_json::Value::Null);
if let Some(target_val) = t.get_mut(key) {
merge_non_default(target_val, other_val, &default_val);
} else if other_val != &default_val {
t.insert(key.clone(), other_val.clone());
}
}
}
(target, other, defaults) => {
if other != defaults {
*target = other.clone();
}
}
}
}
#[cfg(test)]
mod tests {
use crate::settings::*;
#[test]
fn test_db_map_round_trip() {
let settings = Settings {
selected_model: Some("claude-3-5-sonnet-20241022".to_string()),
..Default::default()
};
let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);
assert_eq!(
restored.selected_model,
Some("claude-3-5-sonnet-20241022".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();
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_wasm_channel_owner_ids_db_round_trip() {
let mut settings = Settings::default();
settings
.channels
.wasm_channel_owner_ids
.insert("telegram".to_string(), 123456789);
let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);
assert_eq!(
restored.channels.wasm_channel_owner_ids.get("telegram"),
Some(&123456789)
);
}
#[test]
fn test_wasm_channel_owner_ids_default_empty() {
let settings = Settings::default();
assert!(settings.channels.wasm_channel_owner_ids.is_empty());
}
#[test]
fn test_wasm_channel_owner_ids_via_set() {
let mut settings = Settings::default();
settings
.set("channels.wasm_channel_owner_ids.telegram", "987654321")
.unwrap();
assert_eq!(
settings.channels.wasm_channel_owner_ids.get("telegram"),
Some(&987654321)
);
}
#[test]
fn test_llm_backend_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
let settings = Settings {
llm_backend: Some("anthropic".to_string()),
ollama_base_url: Some("http://localhost:11434".to_string()),
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
..Default::default()
};
let json = serde_json::to_string_pretty(&settings).unwrap();
std::fs::write(&path, json).unwrap();
let loaded = Settings::load_from(&path);
assert_eq!(loaded.llm_backend, Some("anthropic".to_string()));
assert_eq!(
loaded.ollama_base_url,
Some("http://localhost:11434".to_string())
);
assert_eq!(
loaded.openai_compatible_base_url,
Some("http://my-vllm:8000/v1".to_string())
);
}
#[test]
fn test_openai_compatible_db_map_round_trip() {
let settings = Settings {
llm_backend: Some("openai_compatible".to_string()),
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
embeddings: EmbeddingsSettings {
enabled: false,
..Default::default()
},
..Default::default()
};
let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);
assert_eq!(
restored.llm_backend,
Some("openai_compatible".to_string()),
"llm_backend must survive DB round-trip"
);
assert_eq!(
restored.openai_compatible_base_url,
Some("http://my-vllm:8000/v1".to_string()),
"openai_compatible_base_url must survive DB round-trip"
);
assert!(
!restored.embeddings.enabled,
"embeddings.enabled=false must survive DB round-trip"
);
}
#[test]
fn toml_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut settings = Settings::default();
settings.agent.name = "toml-bot".to_string();
settings.heartbeat.enabled = true;
settings.heartbeat.interval_secs = 900;
settings.save_toml(&path).unwrap();
let loaded = Settings::load_toml(&path).unwrap().unwrap();
assert_eq!(loaded.agent.name, "toml-bot");
assert!(loaded.heartbeat.enabled);
assert_eq!(loaded.heartbeat.interval_secs, 900);
}
#[test]
fn db_single_key_model_update_survives_roundtrip() {
let wizard_settings = Settings {
llm_backend: Some("nearai".to_string()),
selected_model: Some("old-wizard-model".to_string()),
..Default::default()
};
let mut db: std::collections::HashMap<String, serde_json::Value> =
wizard_settings.to_db_map();
db.insert(
"selected_model".to_string(),
serde_json::Value::String("new-model".to_string()),
);
let restored = Settings::from_db_map(&db);
assert_eq!(
restored.selected_model,
Some("new-model".to_string()),
"/model change must survive DB round trip"
);
}
#[test]
fn toml_overlay_preserves_matching_model() {
let mut db_settings = Settings {
llm_backend: Some("nearai".to_string()),
selected_model: Some("new-model".to_string()),
..Default::default()
};
let toml_settings = Settings {
selected_model: Some("new-model".to_string()),
..Default::default()
};
db_settings.merge_from(&toml_settings);
assert_eq!(
db_settings.selected_model,
Some("new-model".to_string()),
"TOML overlay must not clobber matching model"
);
}
#[test]
fn stale_toml_overwrites_db_model() {
let mut db_settings = Settings {
selected_model: Some("new-model".to_string()),
..Default::default()
};
let stale_toml = Settings {
selected_model: Some("old-model".to_string()),
..Default::default()
};
db_settings.merge_from(&stale_toml);
assert_eq!(
db_settings.selected_model,
Some("old-model".to_string()),
"TOML overlay has higher priority than DB (by design)"
);
}
#[test]
fn toml_selected_model_update_persists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let settings = Settings {
selected_model: Some("old-model".to_string()),
..Default::default()
};
settings.save_toml(&path).unwrap();
let mut loaded = Settings::load_toml(&path).unwrap().unwrap();
loaded.selected_model = Some("new-model".to_string());
loaded.save_toml(&path).unwrap();
let reloaded = Settings::load_toml(&path).unwrap().unwrap();
assert_eq!(reloaded.selected_model, Some("new-model".to_string()));
}
#[test]
fn toml_created_when_missing_for_model_persist() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
assert!(Settings::load_toml(&path).unwrap().is_none());
let settings = Settings {
selected_model: Some("new-model".to_string()),
..Default::default()
};
settings.save_toml(&path).unwrap();
let loaded = Settings::load_toml(&path).unwrap().unwrap();
assert_eq!(loaded.selected_model, Some("new-model".to_string()));
}
#[test]
fn toml_missing_file_returns_none() {
let result = Settings::load_toml(std::path::Path::new("/tmp/nonexistent_config.toml"));
assert!(result.unwrap().is_none());
}
#[test]
fn toml_invalid_content_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "this is not valid toml [[[").unwrap();
let result = Settings::load_toml(&path);
assert!(result.is_err());
}
#[test]
fn toml_partial_config_uses_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("partial.toml");
std::fs::write(&path, "[agent]\nname = \"partial-bot\"\n").unwrap();
let loaded = Settings::load_toml(&path).unwrap().unwrap();
assert_eq!(loaded.agent.name, "partial-bot");
assert_eq!(loaded.agent.max_parallel_jobs, 5);
assert!(!loaded.heartbeat.enabled);
}
#[test]
fn toml_header_comment_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
Settings::default().save_toml(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("# IronClaw configuration file."));
assert!(content.contains("[agent]"));
assert!(content.contains("[heartbeat]"));
}
#[test]
fn merge_only_overrides_non_default_values() {
let mut base = Settings::default();
base.agent.name = "from-db".to_string();
base.heartbeat.interval_secs = 600;
let mut toml_overlay = Settings::default();
toml_overlay.agent.name = "from-toml".to_string();
base.merge_from(&toml_overlay);
assert_eq!(base.agent.name, "from-toml");
assert_eq!(base.heartbeat.interval_secs, 600);
}
#[test]
fn merge_preserves_base_when_overlay_is_default() {
let mut base = Settings::default();
base.agent.name = "custom-name".to_string();
base.heartbeat.enabled = true;
let overlay = Settings::default();
base.merge_from(&overlay);
assert_eq!(base.agent.name, "custom-name");
assert!(base.heartbeat.enabled);
}
#[test]
fn toml_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("deep").join("config.toml");
Settings::default().save_toml(&path).unwrap();
assert!(path.exists());
}
#[test]
fn default_toml_path_under_ironclaw() {
let path = Settings::default_toml_path();
assert!(path.to_string_lossy().contains(".ironclaw"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn tunnel_settings_round_trip() {
let settings = Settings {
tunnel: TunnelSettings {
provider: Some("ngrok".to_string()),
ngrok_token: Some("tok_abc123".to_string()),
ngrok_domain: Some("my.ngrok.dev".to_string()),
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();
let restored: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(restored.tunnel.provider, Some("ngrok".to_string()));
assert_eq!(restored.tunnel.ngrok_token, Some("tok_abc123".to_string()));
assert_eq!(
restored.tunnel.ngrok_domain,
Some("my.ngrok.dev".to_string())
);
assert!(restored.tunnel.public_url.is_none());
let map = settings.to_db_map();
let from_db = Settings::from_db_map(&map);
assert_eq!(from_db.tunnel.provider, Some("ngrok".to_string()));
assert_eq!(from_db.tunnel.ngrok_token, Some("tok_abc123".to_string()));
let mut s = Settings::default();
s.set("tunnel.provider", "cloudflare").unwrap();
s.set("tunnel.cf_token", "cf_tok_xyz").unwrap();
s.set("tunnel.ts_funnel", "true").unwrap();
assert_eq!(s.tunnel.provider, Some("cloudflare".to_string()));
assert_eq!(s.tunnel.cf_token, Some("cf_tok_xyz".to_string()));
assert!(s.tunnel.ts_funnel);
}
#[test]
fn wizard_recovery_step1_overrides_stale_db() {
let prior_run = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://old-host/ironclaw".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
..Default::default()
},
..Default::default()
};
let db_map = prior_run.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1_settings = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://new-host/ironclaw".to_string()),
..Settings::default()
};
let mut current = step1_settings.clone();
current.merge_from(&from_db);
current.merge_from(&step1_settings);
assert_eq!(
current.database_url,
Some("postgres://new-host/ironclaw".to_string()),
"Step 1 fresh choice must override stale DB value"
);
assert_eq!(
current.llm_backend,
Some("anthropic".to_string()),
"Prior run's LLM backend must be recovered"
);
assert_eq!(
current.selected_model,
Some("claude-sonnet-4-5".to_string()),
"Prior run's model must be recovered"
);
assert!(
current.embeddings.enabled,
"Prior run's embeddings setting must be recovered"
);
}
#[test]
fn wizard_recovery_defaults_dont_clobber_prior() {
let prior_run = Settings {
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 900,
..Default::default()
},
..Default::default()
};
let db_map = prior_run.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
assert_eq!(current.llm_backend, Some("openai".to_string()));
assert_eq!(current.selected_model, Some("gpt-4o".to_string()));
assert!(current.heartbeat.enabled);
assert_eq!(current.heartbeat.interval_secs, 900);
assert_eq!(current.database_backend, Some("libsql".to_string()));
}
#[test]
fn comprehensive_db_map_round_trip() {
let settings = Settings {
onboard_completed: true,
database_backend: Some("libsql".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
openai_compatible_base_url: Some("http://vllm:8000/v1".to_string()),
secrets_master_key_source: KeySource::Keychain,
embeddings: EmbeddingsSettings {
enabled: true,
provider: "nearai".to_string(),
model: "text-embedding-3-large".to_string(),
},
tunnel: TunnelSettings {
provider: Some("ngrok".to_string()),
ngrok_token: Some("tok_xxx".to_string()),
..Default::default()
},
channels: ChannelSettings {
http_enabled: true,
http_port: Some(9090),
wasm_channel_owner_ids: {
let mut m = std::collections::HashMap::new();
m.insert("telegram".to_string(), 12345);
m
},
..Default::default()
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 900,
..Default::default()
},
agent: AgentSettings {
name: "my-bot".to_string(),
max_parallel_jobs: 10,
..Default::default()
},
..Default::default()
};
let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);
assert!(restored.onboard_completed, "onboard_completed lost");
assert_eq!(
restored.database_backend,
Some("libsql".to_string()),
"database_backend lost"
);
assert_eq!(
restored.database_url,
Some("postgres://host/db".to_string()),
"database_url lost"
);
assert_eq!(
restored.llm_backend,
Some("anthropic".to_string()),
"llm_backend lost"
);
assert_eq!(
restored.selected_model,
Some("claude-sonnet-4-5".to_string()),
"selected_model lost"
);
assert_eq!(
restored.openai_compatible_base_url,
Some("http://vllm:8000/v1".to_string()),
"openai_compatible_base_url lost"
);
assert_eq!(
restored.secrets_master_key_source,
KeySource::Keychain,
"key_source lost"
);
assert!(restored.embeddings.enabled, "embeddings.enabled lost");
assert_eq!(
restored.embeddings.provider, "nearai",
"embeddings.provider lost"
);
assert_eq!(
restored.embeddings.model, "text-embedding-3-large",
"embeddings.model lost"
);
assert_eq!(
restored.tunnel.provider,
Some("ngrok".to_string()),
"tunnel.provider lost"
);
assert!(restored.channels.http_enabled, "http_enabled lost");
assert_eq!(restored.channels.http_port, Some(9090), "http_port lost");
assert_eq!(
restored.channels.wasm_channel_owner_ids.get("telegram"),
Some(&12345),
"wasm_channel_owner_ids lost"
);
assert!(restored.heartbeat.enabled, "heartbeat.enabled lost");
assert_eq!(
restored.heartbeat.interval_secs, 900,
"heartbeat.interval_secs lost"
);
assert_eq!(restored.agent.name, "my-bot", "agent.name lost");
assert_eq!(
restored.agent.max_parallel_jobs, 10,
"agent.max_parallel_jobs lost"
);
}
#[test]
fn toml_json_db_all_agree() {
let dir = tempfile::tempdir().unwrap();
let toml_path = dir.path().join("config.toml");
let json_path = dir.path().join("settings.json");
let original = Settings {
llm_backend: Some("ollama".to_string()),
selected_model: Some("llama3".to_string()),
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 600,
..Default::default()
},
agent: AgentSettings {
name: "round-trip-bot".to_string(),
..Default::default()
},
..Default::default()
};
original.save_toml(&toml_path).unwrap();
let from_toml = Settings::load_toml(&toml_path).unwrap().unwrap();
let json = serde_json::to_string_pretty(&original).unwrap();
std::fs::write(&json_path, &json).unwrap();
let from_json = Settings::load_from(&json_path);
let db_map = original.to_db_map();
let from_db = Settings::from_db_map(&db_map);
for (label, loaded) in [("TOML", &from_toml), ("JSON", &from_json), ("DB", &from_db)] {
assert_eq!(
loaded.llm_backend,
Some("ollama".to_string()),
"{label}: llm_backend"
);
assert_eq!(
loaded.selected_model,
Some("llama3".to_string()),
"{label}: selected_model"
);
assert!(loaded.heartbeat.enabled, "{label}: heartbeat.enabled");
assert_eq!(
loaded.heartbeat.interval_secs, 600,
"{label}: heartbeat.interval_secs"
);
assert_eq!(loaded.agent.name, "round-trip-bot", "{label}: agent.name");
}
}
#[test]
fn set_get_round_trip_all_documented_paths() {
let mut settings = Settings::default();
let test_cases: Vec<(&str, &str)> = vec![
("agent.name", "test-agent"),
("agent.max_parallel_jobs", "8"),
("heartbeat.enabled", "true"),
("heartbeat.interval_secs", "300"),
("channels.http_enabled", "true"),
("channels.http_port", "8081"),
];
for (path, value) in &test_cases {
settings
.set(path, value)
.unwrap_or_else(|e| panic!("set({path}, {value}) failed: {e}"));
let got = settings
.get(path)
.unwrap_or_else(|| panic!("get({path}) returned None after set"));
assert_eq!(&got, value, "set/get round-trip failed for path '{path}'");
}
}
#[test]
fn option_string_fields_survive_db_round_trip_as_null() {
let settings = Settings {
database_url: None,
llm_backend: None,
selected_model: None,
openai_compatible_base_url: None,
..Default::default()
};
let map = settings.to_db_map();
let restored = Settings::from_db_map(&map);
assert_eq!(
restored.database_url, None,
"None database_url should stay None"
);
assert_eq!(
restored.llm_backend, None,
"None llm_backend should stay None"
);
assert_eq!(
restored.selected_model, None,
"None selected_model should stay None"
);
}
#[test]
fn provider_only_rerun_preserves_unrelated_settings() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
model: "text-embedding-3-small".to_string(),
},
channels: ChannelSettings {
http_enabled: true,
http_port: Some(8080),
signal_enabled: true,
signal_account: Some("+1234567890".to_string()),
wasm_channels: vec!["telegram".to_string()],
..Default::default()
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 900,
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
let mut current = Settings::from_db_map(&db_map);
current.llm_backend = Some("anthropic".to_string());
current.selected_model = None;
current.selected_model = Some("claude-sonnet-4-5".to_string());
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5"));
assert!(current.channels.http_enabled, "HTTP channel must survive");
assert_eq!(current.channels.http_port, Some(8080));
assert!(current.channels.signal_enabled, "Signal must survive");
assert_eq!(
current.channels.wasm_channels,
vec!["telegram".to_string()],
"WASM channels must survive"
);
assert!(current.embeddings.enabled, "Embeddings must survive");
assert_eq!(current.embeddings.provider, "openai");
assert!(current.heartbeat.enabled, "Heartbeat must survive");
assert_eq!(current.heartbeat.interval_secs, 900);
assert_eq!(
current.database_backend.as_deref(),
Some("libsql"),
"DB backend must survive"
);
}
#[test]
fn channels_only_rerun_preserves_unrelated_settings() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "nearai".to_string(),
model: "text-embedding-3-small".to_string(),
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 1800,
..Default::default()
},
channels: ChannelSettings {
http_enabled: false,
wasm_channels: vec!["telegram".to_string()],
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
let mut current = Settings::from_db_map(&db_map);
current.channels.http_enabled = true;
current.channels.http_port = Some(9090);
current.channels.wasm_channels = vec!["telegram".to_string(), "discord".to_string()];
assert!(current.channels.http_enabled);
assert_eq!(current.channels.http_port, Some(9090));
assert_eq!(current.channels.wasm_channels.len(), 2);
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5"));
assert!(current.embeddings.enabled);
assert_eq!(current.embeddings.provider, "nearai");
assert!(current.heartbeat.enabled);
assert_eq!(current.heartbeat.interval_secs, 1800);
}
#[test]
fn quick_mode_rerun_preserves_prior_channels_and_heartbeat() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
channels: ChannelSettings {
http_enabled: true,
http_port: Some(8080),
signal_enabled: true,
wasm_channels: vec!["telegram".to_string()],
..Default::default()
},
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
model: "text-embedding-3-small".to_string(),
},
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 600,
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
current.llm_backend = Some("anthropic".to_string());
current.selected_model = None;
current.selected_model = Some("claude-opus-4-6".to_string());
assert_eq!(current.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(current.selected_model.as_deref(), Some("claude-opus-4-6"));
assert!(
current.channels.http_enabled,
"HTTP channel must survive quick mode re-run"
);
assert_eq!(current.channels.http_port, Some(8080));
assert!(
current.channels.signal_enabled,
"Signal must survive quick mode re-run"
);
assert_eq!(
current.channels.wasm_channels,
vec!["telegram".to_string()],
"WASM channels must survive quick mode re-run"
);
assert!(
current.embeddings.enabled,
"Embeddings must survive quick mode re-run"
);
assert!(
current.heartbeat.enabled,
"Heartbeat must survive quick mode re-run"
);
assert_eq!(current.heartbeat.interval_secs, 600);
}
#[test]
fn full_rerun_same_provider_preserves_model_through_merge() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
assert_eq!(
current.llm_backend.as_deref(),
Some("anthropic"),
"Prior provider must be recovered from DB"
);
assert_eq!(
current.selected_model.as_deref(),
Some("claude-sonnet-4-5"),
"Prior model must be recovered from DB"
);
let backend_changed = current.llm_backend.as_deref() != Some("anthropic");
current.llm_backend = Some("anthropic".to_string());
if backend_changed {
current.selected_model = None;
}
assert_eq!(
current.selected_model.as_deref(),
Some("claude-sonnet-4-5"),
"Model must survive when re-selecting same provider"
);
}
#[test]
fn full_rerun_different_provider_clears_model_through_merge() {
let prior = Settings {
onboard_completed: true,
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
let backend_changed = current.llm_backend.as_deref() != Some("openai");
assert!(backend_changed, "switching providers should be detected");
current.llm_backend = Some("openai".to_string());
if backend_changed {
current.selected_model = None;
}
assert_eq!(current.llm_backend.as_deref(), Some("openai"));
assert!(
current.selected_model.is_none(),
"Model must be cleared when switching providers"
);
}
#[test]
fn incremental_persist_does_not_clobber_prior_steps() {
let after_step2 = Settings {
database_backend: Some("libsql".to_string()),
secrets_master_key_source: KeySource::Keychain,
..Default::default()
};
let db_map_after_step2 = after_step2.to_db_map();
let mut after_step3 = after_step2.clone();
after_step3.llm_backend = Some("openai".to_string());
let db_map_after_step3 = after_step3.to_db_map();
let restored = Settings::from_db_map(&db_map_after_step3);
assert_eq!(
restored.secrets_master_key_source,
KeySource::Keychain,
"Step 2 security setting must survive step 3 persist"
);
assert_eq!(
restored.database_backend.as_deref(),
Some("libsql"),
"Step 1 DB setting must survive step 3 persist"
);
assert_eq!(
restored.llm_backend.as_deref(),
Some("openai"),
"Step 3 provider setting must be saved"
);
let from_step2_db = Settings::from_db_map(&db_map_after_step2);
let mut merged = after_step3.clone();
merged.merge_from(&from_step2_db);
assert_eq!(
merged.llm_backend.as_deref(),
Some("openai"),
"Step 3 provider must not be clobbered by step 2 snapshot merge"
);
assert_eq!(
merged.secrets_master_key_source,
KeySource::Keychain,
"Step 2 security must survive merge"
);
}
#[test]
fn switching_db_backend_allows_fresh_connection_settings() {
let prior = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://host/db".to_string()),
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("libsql".to_string()),
libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()),
database_url: None, ..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
assert_eq!(current.database_backend.as_deref(), Some("libsql"));
assert_eq!(
current.libsql_path.as_deref(),
Some("/home/user/.ironclaw/ironclaw.db")
);
assert_eq!(current.llm_backend.as_deref(), Some("openai"));
assert_eq!(current.selected_model.as_deref(), Some("gpt-4o"));
assert_eq!(
current.database_url.as_deref(),
Some("postgres://host/db"),
"stale database_url persists (harmless, ignored by libsql backend)"
);
}
#[test]
fn merge_preserves_true_booleans_when_overlay_has_default_false() {
let prior = Settings {
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 600,
..Default::default()
},
channels: ChannelSettings {
http_enabled: true,
signal_enabled: true,
..Default::default()
},
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
assert!(
current.heartbeat.enabled,
"heartbeat.enabled=true must not be reset to false by default overlay"
);
assert!(
current.channels.http_enabled,
"http_enabled=true must not be reset to false by default overlay"
);
assert!(
current.channels.signal_enabled,
"signal_enabled=true must not be reset to false by default overlay"
);
assert_eq!(current.heartbeat.interval_secs, 600);
}
#[test]
fn embeddings_survive_rerun_that_skips_step5() {
let prior = Settings {
onboard_completed: true,
llm_backend: Some("nearai".to_string()),
selected_model: Some("qwen".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "nearai".to_string(),
model: "text-embedding-3-large".to_string(),
},
..Default::default()
};
let db_map = prior.to_db_map();
let from_db = Settings::from_db_map(&db_map);
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
assert!(current.embeddings.enabled);
assert_eq!(current.embeddings.provider, "nearai");
assert_eq!(current.embeddings.model, "text-embedding-3-large");
}
}