use super::*;
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
impl Config {
pub fn voice_config(&self) -> VoiceConfig {
let stt = self.providers.stt.as_ref();
let tts = self.providers.tts.as_ref();
let groq_enabled = stt.and_then(|s| s.groq.as_ref()).is_some_and(|g| g.enabled);
let local_stt_enabled = stt
.and_then(|s| s.local.as_ref())
.is_some_and(|l| l.enabled);
let openai_compatible_stt_enabled = stt
.and_then(|s| s.openai_compatible.as_ref())
.is_some_and(|c| c.enabled);
let voicebox_stt_enabled = stt
.and_then(|s| s.voicebox.as_ref())
.is_some_and(|v| v.enabled);
let stt_enabled = groq_enabled
|| local_stt_enabled
|| openai_compatible_stt_enabled
|| voicebox_stt_enabled;
let stt_mode = if local_stt_enabled {
SttMode::Local
} else {
SttMode::Api
};
let local_stt_model = stt
.and_then(|s| s.local.as_ref())
.map(|l| l.model.clone())
.unwrap_or_else(default_local_stt_model);
let stt_base_url = stt
.and_then(|s| s.openai_compatible.as_ref())
.and_then(|c| c.base_url.clone())
.or_else(|| groq_enabled.then(|| "https://api.groq.com/openai/v1".to_string()));
let stt_model = stt
.and_then(|s| s.openai_compatible.as_ref())
.and_then(|c| c.model.clone())
.or_else(|| Some("whisper-large-v3-turbo".to_string()));
let stt_api_key = stt
.and_then(|s| s.openai_compatible.as_ref())
.and_then(|c| c.api_key.clone())
.or_else(|| {
stt.and_then(|s| s.groq.as_ref())
.and_then(|g| g.api_key.clone())
});
let openai_tts_enabled = tts
.and_then(|t| t.openai.as_ref())
.is_some_and(|o| o.enabled);
let local_tts_enabled = tts
.and_then(|t| t.local.as_ref())
.is_some_and(|l| l.enabled);
let openai_compatible_tts_enabled = tts
.and_then(|t| t.openai_compatible.as_ref())
.is_some_and(|c| c.enabled);
let voicebox_tts_enabled = tts
.and_then(|t| t.voicebox.as_ref())
.is_some_and(|v| v.enabled);
let tts_enabled = openai_tts_enabled
|| local_tts_enabled
|| openai_compatible_tts_enabled
|| voicebox_tts_enabled;
let tts_mode = if local_tts_enabled {
TtsMode::Local
} else {
TtsMode::Api
};
let tts_voice = tts
.and_then(|t| t.openai.as_ref())
.and_then(|o| o.voice.clone())
.or_else(|| {
tts.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.voice.clone())
})
.unwrap_or_else(default_tts_voice);
let tts_model = tts
.and_then(|t| t.openai.as_ref())
.and_then(|o| o.model.clone().or_else(|| o.default_model.clone()))
.or_else(|| {
tts.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.model.clone())
})
.unwrap_or_else(default_tts_model);
let tts_base_url = tts
.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.base_url.clone())
.or_else(|| openai_tts_enabled.then(|| "https://api.openai.com".to_string()));
let tts_api_key = tts
.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.api_key.clone())
.or_else(|| {
tts.and_then(|t| t.openai.as_ref())
.and_then(|o| o.api_key.clone())
});
let local_tts_voice = tts
.and_then(|t| t.local.as_ref())
.map(|l| l.voice.clone())
.unwrap_or_else(default_local_tts_voice);
let voicebox_stt_base_url = stt
.and_then(|s| s.voicebox.as_ref())
.map(|v| v.base_url.clone())
.unwrap_or_else(default_voicebox_url);
let voicebox_tts_base_url = tts
.and_then(|t| t.voicebox.as_ref())
.map(|v| v.base_url.clone())
.unwrap_or_else(default_voicebox_url);
let voicebox_tts_profile_id = tts
.and_then(|t| t.voicebox.as_ref())
.map(|v| v.profile_id.clone())
.unwrap_or_default();
let voicebox_tts_engine = tts
.and_then(|t| t.voicebox.as_ref())
.map(|v| v.engine.clone())
.unwrap_or_default();
let stt_provider = stt.and_then(|s| s.groq.clone());
let tts_provider = tts.and_then(|t| t.openai.clone());
let stt_fallback_chain = stt
.and_then(|s| s.fallback_chain.clone())
.unwrap_or_default();
let tts_fallback_chain = tts
.and_then(|t| t.fallback_chain.clone())
.unwrap_or_default();
VoiceConfig {
stt_enabled,
stt_mode,
local_stt_model,
stt_base_url,
stt_model,
stt_api_key,
tts_enabled,
tts_mode,
tts_voice,
tts_model,
tts_base_url,
tts_api_key,
local_tts_voice,
stt_provider,
tts_provider,
voicebox_stt_enabled,
voicebox_stt_base_url,
voicebox_tts_enabled,
voicebox_tts_base_url,
voicebox_tts_profile_id,
voicebox_tts_engine,
stt_fallback_chain,
tts_fallback_chain,
}
}
pub fn load() -> Result<Self> {
match Self::load_inner() {
Ok(config) => Ok(config),
Err(e) => {
tracing::error!("Config load failed: {e:#} — attempting auto-repair");
if let Some(fixed) = Self::try_autofix_configs() {
CONFIG_AUTOFIXED.store(true, std::sync::atomic::Ordering::Relaxed);
return Ok(fixed);
}
tracing::error!("Auto-repair did not resolve it — trying last-known-good");
if let Some(good) = load_last_good_config() {
CONFIG_RECOVERED.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(good)
} else {
Err(e)
}
}
}
}
fn try_autofix_configs() -> Option<Self> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(p) = Self::system_config_path() {
candidates.push(p);
}
candidates.push(Self::local_config_path());
let repaired_any = {
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::config::repair::autofix_config_files(&candidates)
};
if !repaired_any {
return None;
}
match Self::load_inner() {
Ok(cfg) => Some(cfg),
Err(e) => {
tracing::error!("Auto-fix wrote repairs but config still fails to load: {e}");
None
}
}
}
pub fn was_autofixed() -> bool {
CONFIG_AUTOFIXED.swap(false, std::sync::atomic::Ordering::Relaxed)
}
pub fn was_recovered() -> bool {
CONFIG_RECOVERED.swap(false, std::sync::atomic::Ordering::Relaxed)
}
fn load_inner() -> Result<Self> {
tracing::trace!("Loading configuration...");
let mut config = Self::default();
if let Some(system_config_path) = Self::system_config_path()
&& system_config_path.exists()
{
tracing::trace!("Loading system config from: {:?}", system_config_path);
config = Self::merge_from_file(config, &system_config_path)?;
}
let local_config_path = Self::local_config_path();
if local_config_path.exists() {
tracing::trace!("Loading local config from: {:?}", local_config_path);
config = Self::merge_from_file(config, &local_config_path)?;
}
if let Some(ref path) = Self::system_config_path() {
Self::migrate_if_needed(path);
}
match load_keys_from_file() {
Err(e) => {
tracing::error!("Failed to load keys.toml: {:#}", e);
let keys_good = opencrabs_home().join("keys.last_good.toml");
if keys_good.exists() {
match fs::read_to_string(&keys_good)
.context("reading keys.last_good.toml")
.and_then(|content| {
toml::from_str::<KeysFile>(&content)
.context("parsing keys.last_good.toml")
}) {
Ok(keys) => {
tracing::warn!(
"Recovered API keys from keys.last_good.toml — \
fix or delete keys.toml to clear this warning"
);
config.providers =
merge_provider_keys(config.providers, keys.providers);
config.channels = merge_channel_keys(config.channels, keys.channels);
if let Some(a2a_keys) = keys.a2a
&& let Some(key) = a2a_keys.api_key
&& !key.is_empty()
{
config.a2a.api_key = Some(key);
}
}
Err(e2) => {
tracing::error!(
"keys.last_good.toml also failed: {:#} — no API keys loaded",
e2
);
}
}
} else {
tracing::error!("No keys.last_good.toml backup — no API keys loaded");
}
}
Ok(keys) => {
config.providers = merge_provider_keys(config.providers, keys.providers);
config.channels = merge_channel_keys(config.channels, keys.channels);
if let Some(a2a_keys) = keys.a2a
&& let Some(key) = a2a_keys.api_key
&& !key.is_empty()
{
config.a2a.api_key = Some(key);
}
let image_key = config
.providers
.image
.as_ref()
.and_then(|img| img.gemini.as_ref())
.and_then(|g| g.api_key.as_ref())
.filter(|k| !k.is_empty())
.cloned()
.or_else(|| {
keys.image
.and_then(|img| img.api_key)
.filter(|k| !k.is_empty())
});
if let Some(key) = image_key {
config.image.generation.api_key = Some(key.clone());
config.image.vision.api_key = Some(key);
}
}
}
config = Self::apply_env_overrides(config)?;
config.database.path = expand_tilde(&config.database.path);
if let Some(path) = Self::system_config_path()
&& path.exists()
{
Self::warn_unknown_keys(&path);
}
tracing::trace!("Configuration loaded successfully");
Ok(config)
}
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"crabrace",
"database",
"logging",
"debug",
"providers",
"channels",
"agent",
"daemon",
"a2a",
"gateway",
"image",
"cron",
"memory",
];
fn warn_unknown_keys(path: &Path) {
use std::sync::atomic::{AtomicBool, Ordering};
static CHECKED: AtomicBool = AtomicBool::new(false);
if CHECKED.swap(true, Ordering::Relaxed) {
return;
}
let Ok(raw) = std::fs::read_to_string(path) else {
return;
};
let Ok(table) = raw.parse::<toml::Table>() else {
return;
};
let mut unknown: Vec<String> = Vec::new();
for key in table.keys() {
if !Self::KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
unknown.push(key.clone());
}
}
if !unknown.is_empty() {
tracing::warn!(
"Unknown top-level keys in config.toml (possible typos): {}",
unknown.join(", ")
);
CONFIG_TYPO_WARNINGS
.lock()
.unwrap_or_else(|e| e.into_inner())
.extend(unknown);
}
}
pub fn take_typo_warnings() -> Vec<String> {
CONFIG_TYPO_WARNINGS
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain(..)
.collect()
}
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
tracing::debug!("Loading configuration from custom path: {:?}", path);
let mut config = Self::default();
if path.exists() {
config = Self::merge_from_file(config, path)?;
} else {
anyhow::bail!("Config file not found: {:?}", path);
}
config = Self::apply_env_overrides(config)?;
config.database.path = expand_tilde(&config.database.path);
tracing::debug!("Configuration loaded successfully from custom path");
Ok(config)
}
fn migrate_if_needed(path: &Path) {
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Ok(content) = fs::read_to_string(path) else {
return;
};
let mut doc: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => return,
};
let mut changed = false;
if let Some(trello) = doc
.get_mut("channels")
.and_then(|c| c.get_mut("trello"))
.and_then(|t| t.as_table_mut())
&& let Some(val) = trello.remove("allowed_channels")
&& !trello.contains_key("board_ids")
{
trello.insert("board_ids".to_string(), val);
changed = true;
}
if let Some(voice) = doc.get("voice").and_then(|v| v.as_table()).cloned() {
let root = doc.as_table_mut().unwrap();
if !root.contains_key("providers") {
root.insert(
"providers".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let providers = root.get_mut("providers").unwrap().as_table_mut().unwrap();
if !providers.contains_key("stt") {
providers.insert("stt".to_string(), toml::Value::Table(toml::map::Map::new()));
}
if !providers.contains_key("tts") {
providers.insert("tts".to_string(), toml::Value::Table(toml::map::Map::new()));
}
let stt_enabled = voice
.get("stt_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let stt_mode = voice
.get("stt_mode")
.and_then(|v| v.as_str())
.unwrap_or("api")
.to_string();
if stt_enabled {
let stt = providers.get_mut("stt").unwrap().as_table_mut().unwrap();
if stt_mode == "local" {
if !stt.contains_key("local") {
stt.insert(
"local".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let local = stt.get_mut("local").unwrap().as_table_mut().unwrap();
local.entry("enabled").or_insert(toml::Value::Boolean(true));
if let Some(model) = voice.get("local_stt_model") {
local.entry("model").or_insert(model.clone());
}
} else if let Some(groq) = stt.get_mut("groq").and_then(|g| g.as_table_mut()) {
groq.entry("enabled").or_insert(toml::Value::Boolean(true));
}
}
let tts_enabled = voice
.get("tts_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let tts_mode = voice
.get("tts_mode")
.and_then(|v| v.as_str())
.unwrap_or("api")
.to_string();
if tts_enabled {
let tts = providers.get_mut("tts").unwrap().as_table_mut().unwrap();
if tts_mode == "local" {
if !tts.contains_key("local") {
tts.insert(
"local".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let local = tts.get_mut("local").unwrap().as_table_mut().unwrap();
local.entry("enabled").or_insert(toml::Value::Boolean(true));
if let Some(voice_name) = voice.get("local_tts_voice") {
local.entry("voice").or_insert(voice_name.clone());
}
} else if let Some(openai) = tts.get_mut("openai").and_then(|o| o.as_table_mut()) {
openai
.entry("enabled")
.or_insert(toml::Value::Boolean(true));
if let Some(v) = voice.get("tts_voice") {
openai.entry("voice").or_insert(v.clone());
}
if let Some(m) = voice.get("tts_model") {
openai.entry("model").or_insert(m.clone());
}
}
}
root.remove("voice");
changed = true;
}
if !changed {
let content = fs::read_to_string(path).unwrap_or_default();
let has_subagent =
content.contains("subagent_provider") || content.contains("subagent_model");
if !has_subagent && let Ok(injected) = inject_subagent_defaults(&content) {
match fs::write(path, &injected) {
Ok(()) => {
tracing::info!("Config migrated: injected subagent defaults into [agent]")
}
Err(e) => tracing::warn!(
"Config migration: failed to write injected defaults to {}: {e}",
path.display()
),
}
}
return;
}
let Ok(mut edit_doc) = content.parse::<toml_edit::DocumentMut>() else {
tracing::warn!(
"Config migration: failed to parse config.toml for format-preserving write"
);
return;
};
if let Some(trello) = edit_doc
.get_mut("channels")
.and_then(|c| c.as_table_mut())
.and_then(|c| c.get_mut("trello"))
.and_then(|t| t.as_table_mut())
&& let Some(val) = trello.remove("allowed_channels")
&& trello.get("board_ids").is_none()
{
trello.insert("board_ids", val);
}
edit_doc.as_table_mut().remove("voice");
Self::backup_config(path, 7);
if fs::write(path, edit_doc.to_string()).is_ok() {
tracing::info!("Config migrated: [voice] → providers.stt/tts");
}
let updated_content = fs::read_to_string(path).unwrap_or_default();
let has_subagent = updated_content.contains("subagent_provider")
|| updated_content.contains("subagent_model");
if !has_subagent
&& let Ok(injected) = inject_subagent_defaults(&updated_content)
&& let Err(e) = fs::write(path, &injected)
{
tracing::warn!("Config migration: failed to inject subagent defaults: {e}");
}
}
pub fn system_config_path() -> Option<PathBuf> {
Some(opencrabs_home().join("config.toml"))
}
fn local_config_path() -> PathBuf {
PathBuf::from("./opencrabs.toml")
}
fn merge_from_file(base: Self, path: &Path) -> Result<Self> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let file_config: Self = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(Self::merge(base, file_config))
}
fn merge(_base: Self, overlay: Self) -> Self {
Self {
crabrace: overlay.crabrace,
database: overlay.database,
logging: overlay.logging,
debug: overlay.debug,
providers: overlay.providers,
channels: overlay.channels,
agent: overlay.agent,
daemon: overlay.daemon,
a2a: overlay.a2a,
image: overlay.image,
cron: overlay.cron,
memory: overlay.memory,
brain: overlay.brain,
browser: overlay.browser,
}
}
fn apply_env_overrides(mut config: Self) -> Result<Self> {
if let Ok(db_path) = std::env::var("OPENCRABS_DB_PATH") {
config.database.path = PathBuf::from(db_path);
}
if let Ok(log_level) = std::env::var("OPENCRABS_LOG_LEVEL") {
config.logging.level = log_level;
}
if let Ok(log_file) = std::env::var("OPENCRABS_LOG_FILE") {
config.logging.file = Some(PathBuf::from(log_file));
}
if let Ok(debug_lsp) = std::env::var("OPENCRABS_DEBUG_LSP") {
config.debug.debug_lsp = debug_lsp.parse().unwrap_or(false);
}
if let Ok(profiling) = std::env::var("OPENCRABS_PROFILING") {
config.debug.profiling = profiling.parse().unwrap_or(false);
}
if let Ok(enabled) = std::env::var("OPENCRABS_CRABRACE_ENABLED") {
config.crabrace.enabled = enabled.parse().unwrap_or(true);
}
if let Ok(base_url) = std::env::var("OPENCRABS_CRABRACE_URL") {
config.crabrace.base_url = base_url;
}
if let Ok(auto_update) = std::env::var("OPENCRABS_CRABRACE_AUTO_UPDATE") {
config.crabrace.auto_update = auto_update.parse().unwrap_or(true);
}
Ok(config)
}
pub fn reload() -> Result<Self> {
tracing::info!("Reloading configuration from disk");
Self::load()
}
pub fn write_key(section: &str, key: &str, value: &str) -> Result<()> {
use toml_edit::DocumentMut;
let value = value.trim();
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path =
Self::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<String> = section
.split('.')
.enumerate()
.map(|(i, p)| {
if i >= 2 && section.starts_with("providers.custom") {
normalize_toml_key(p)
} else {
p.to_string()
}
})
.collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part.as_str()).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part.as_str())
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
let parsed: toml_edit::Item = if value.starts_with('[') && value.ends_with(']') {
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(value) {
let mut toml_arr = toml_edit::Array::new();
for v in arr {
match v {
serde_json::Value::String(s) => {
toml_arr.push(s);
}
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml_arr.push(i);
} else if let Some(f) = n.as_f64() {
toml_arr.push(f);
}
}
serde_json::Value::Bool(b) => {
toml_arr.push(b);
}
_ => {}
}
}
toml_edit::value(toml_arr)
} else {
toml_edit::value(value)
}
} else if let Ok(v) = value.parse::<i64>() {
toml_edit::value(v)
} else if let Ok(v) = value.parse::<f64>() {
toml_edit::value(v)
} else if let Ok(v) = value.parse::<bool>() {
toml_edit::value(v)
} else {
toml_edit::value(value)
};
current.insert(key, parsed);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!("Wrote config key [{section}].{key}");
Ok(())
}
pub fn write_keys_key(section: &str, key: &str, value: &str) -> Result<()> {
use toml_edit::DocumentMut;
let value = value.trim();
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = opencrabs_home().join("keys.toml");
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<String> = section.split('.').map(|p| p.to_string()).collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part.as_str()).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part.as_str())
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
let parsed: toml_edit::Item = toml_edit::value(value);
current.insert(key, parsed);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!("Wrote keys.toml key [{section}].{key}");
Ok(())
}
pub fn remove_secret_section(section: &str) -> Result<()> {
use toml_edit::DocumentMut;
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = opencrabs_home().join("keys.toml");
if !path.exists() {
return Ok(());
}
let mut doc: DocumentMut = fs::read_to_string(&path)?.parse()?;
let parts: Vec<&str> = section.split('.').collect();
if parts.is_empty() {
return Ok(());
}
let parent_parts = &parts[..parts.len() - 1];
let leaf = parts[parts.len() - 1];
let mut current = doc.as_table_mut();
for part in parent_parts {
match current.get_mut(part) {
Some(v) if v.is_table() => {
current = v.as_table_mut().unwrap();
}
_ => return Ok(()),
}
}
if current.remove(leaf).is_some() {
tracing::info!("Removed keys.toml section [{section}]");
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
}
Ok(())
}
pub fn remove_section(section: &str) -> Result<()> {
use toml_edit::DocumentMut;
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path =
Self::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
if !path.exists() {
return Ok(());
}
let mut doc: DocumentMut = fs::read_to_string(&path)?.parse()?;
let parts: Vec<&str> = section.split('.').collect();
if parts.is_empty() {
return Ok(());
}
let parent_parts = &parts[..parts.len() - 1];
let leaf = parts[parts.len() - 1];
let mut current = doc.as_table_mut();
for part in parent_parts {
match current.get_mut(part) {
Some(v) if v.is_table() => {
current = v.as_table_mut().unwrap();
}
_ => return Ok(()), }
}
current.remove(leaf);
tracing::info!("Removed config section [{section}]");
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
Ok(())
}
pub fn cleanup_empty_custom_providers() {
if let Ok(config) = Self::load()
&& let Some(customs) = &config.providers.custom
{
for (name, cfg) in customs {
let is_empty_name = name.is_empty();
let has_url = cfg.base_url.as_ref().is_some_and(|u| !u.is_empty());
let has_model = cfg.default_model.as_ref().is_some_and(|m| !m.is_empty());
if is_empty_name || (!has_url && !has_model) {
let section = format!("providers.custom.{}", name);
if let Err(e) = Self::remove_section(§ion) {
tracing::warn!("Failed to remove empty custom provider '{}': {}", name, e);
}
}
}
}
Self::cleanup_keys_custom_providers();
}
pub(crate) fn cleanup_keys_custom_providers() {
use toml_edit::DocumentMut;
let keys_file = keys_path();
if !keys_file.exists() {
return;
}
let Ok(content) = std::fs::read_to_string(&keys_file) else {
return;
};
let Ok(mut doc) = content.parse::<DocumentMut>() else {
return;
};
let custom_table = match doc
.as_table_mut()
.get_mut("providers")
.and_then(|t| t.as_table_mut())
.and_then(|t| t.get_mut("custom"))
.and_then(|t| t.as_table_mut())
{
Some(t) => t,
None => return,
};
let config_names: std::collections::HashSet<String> = raw_config_custom_provider_names();
let remove: Vec<String> = custom_table
.iter()
.map(|(k, _)| k.to_string())
.filter(|k| k.is_empty() || !config_names.contains(k))
.collect();
if remove.is_empty() {
return;
}
for key in &remove {
custom_table.remove(key);
tracing::info!("Removed ghost key from keys.toml: providers.custom.{}", key);
}
daily_backup(&keys_file, 7);
if let Err(e) = std::fs::write(&keys_file, doc.to_string()) {
tracing::warn!("Failed to clean ghost keys from keys.toml: {e}");
}
}
pub fn write_array(section: &str, key: &str, values: &[String]) -> Result<()> {
use toml_edit::DocumentMut;
let path =
Self::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<&str> = section.split('.').collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part)
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
let mut arr = toml_edit::Array::new();
for v in values {
arr.push(v.as_str());
}
current.insert(key, toml_edit::value(arr));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!(
"Wrote config array [{section}].{key} ({} items)",
values.len()
);
Ok(())
}
pub fn has_any_api_key(&self) -> bool {
let has_anthropic = self
.providers
.anthropic
.as_ref()
.is_some_and(|p| p.api_key.is_some());
let has_openai = self
.providers
.openai
.as_ref()
.is_some_and(|p| p.api_key.is_some());
let has_gemini = self
.providers
.gemini
.as_ref()
.is_some_and(|p| p.api_key.is_some());
has_anthropic || has_openai || has_gemini
}
pub fn validate(&self) -> Result<()> {
tracing::debug!("Validating configuration...");
if let Some(parent) = self.database.path.parent()
&& !parent.exists()
{
tracing::warn!(
"Database parent directory does not exist, will be created: {:?}",
parent
);
}
let valid_levels = ["trace", "debug", "info", "warn", "error"];
if !valid_levels.contains(&self.logging.level.as_str()) {
anyhow::bail!(
"Invalid log level: {}. Must be one of: {:?}",
self.logging.level,
valid_levels
);
}
if self.crabrace.enabled && self.crabrace.base_url.is_empty() {
anyhow::bail!("Crabrace is enabled but base_url is empty");
}
tracing::debug!("Configuration validation passed");
Ok(())
}
fn backup_config(path: &Path, max_days: usize) {
daily_backup(path, max_days);
}
pub fn save(&self, path: &Path) -> Result<()> {
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let toml_string =
toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
Self::backup_config(path, 7);
fs::write(path, toml_string)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
tracing::info!("Configuration saved to: {:?}", path);
Ok(())
}
}
fn inject_subagent_defaults(content: &str) -> Result<String> {
let comment_block = "\n# Sub-agent routing — override the parent session's provider for\n# spawned agents, team members, and background workers.\n# subagent_provider = \"anthropic\" # e.g. openrouter, minimax, custom:ollama\n# subagent_model = \"claude-sonnet-4-6\" # only used when subagent_provider is set\n";
let marker = "[agent]";
let agent_pos = content
.find(marker)
.ok_or_else(|| anyhow::anyhow!("No [agent] section found"))?;
let section_start = agent_pos + marker.len();
let rest = &content[section_start..];
let next_section = rest.find("\n[");
let section_end = if let Some(pos) = next_section {
section_start + pos
} else {
content.len()
};
let mut out = String::with_capacity(content.len() + comment_block.len());
out.push_str(&content[..section_end]);
let before = &content[..section_end];
let ends_with_blank = before.ends_with("\n\n");
if !ends_with_blank {
out.push('\n');
}
out.push_str(comment_block);
out.push_str(&content[section_end..]);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.crabrace.enabled);
assert_eq!(config.logging.level, "info");
assert!(!config.debug.debug_lsp);
assert!(!config.debug.profiling);
}
#[test]
fn test_config_validation() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validation_invalid_log_level() {
let mut config = Config::default();
config.logging.level = "invalid".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_config_validation_empty_crabrace_url() {
let mut config = Config::default();
config.crabrace.base_url = String::new();
assert!(config.validate().is_err());
}
#[test]
fn test_config_from_toml() {
let toml_content = r#"
[database]
path = "/custom/path/db.sqlite"
[logging]
level = "debug"
[debug]
debug_lsp = true
profiling = true
[crabrace]
enabled = false
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(
config.database.path,
PathBuf::from("/custom/path/db.sqlite")
);
assert_eq!(config.logging.level, "debug");
assert!(config.debug.debug_lsp);
assert!(config.debug.profiling);
assert!(!config.crabrace.enabled);
}
#[test]
fn test_config_save_and_load() {
let temp_file = NamedTempFile::new().unwrap();
let config = Config::default();
config.save(temp_file.path()).unwrap();
let contents = std::fs::read_to_string(temp_file.path()).unwrap();
let loaded_config: Config = toml::from_str(&contents).unwrap();
assert_eq!(loaded_config.logging.level, config.logging.level);
assert_eq!(loaded_config.crabrace.enabled, config.crabrace.enabled);
}
#[test]
fn test_config_from_toml_overrides() {
let toml_content = r#"
[logging]
level = "trace"
[debug]
debug_lsp = true
profiling = true
[database]
path = "/tmp/test.db"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.logging.level, "trace");
assert!(config.debug.debug_lsp);
assert!(config.debug.profiling);
assert_eq!(config.database.path, PathBuf::from("/tmp/test.db"));
}
#[test]
fn test_provider_config_from_toml() {
let toml_content = r#"
[providers.anthropic]
enabled = true
api_key = "test-anthropic-key"
default_model = "claude-opus-4-6"
[providers.openai]
enabled = true
api_key = "test-openai-key"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert!(config.providers.anthropic.is_some());
let anthropic = config.providers.anthropic.as_ref().unwrap();
assert_eq!(anthropic.api_key, Some("test-anthropic-key".to_string()));
assert_eq!(anthropic.default_model, Some("claude-opus-4-6".to_string()));
assert!(config.providers.openai.is_some());
assert_eq!(
config.providers.openai.as_ref().unwrap().api_key,
Some("test-openai-key".to_string())
);
}
#[test]
fn test_system_config_path() {
let path = Config::system_config_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(path.to_string_lossy().contains("opencrabs"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn test_local_config_path() {
let path = Config::local_config_path();
assert_eq!(path, PathBuf::from("./opencrabs.toml"));
}
#[test]
fn test_debug_config_default() {
let debug = DebugConfig::default();
assert!(!debug.debug_lsp);
assert!(!debug.profiling);
}
#[test]
fn test_provider_configs_default() {
let providers = ProviderConfigs::default();
assert!(providers.anthropic.is_none());
assert!(providers.openai.is_none());
assert!(providers.gemini.is_none());
assert!(providers.bedrock.is_none());
assert!(providers.vertex.is_none());
}
#[test]
fn test_database_config_default() {
let db_config = DatabaseConfig::default();
assert!(!db_config.path.as_os_str().is_empty());
}
#[test]
fn test_logging_config_default() {
let logging = LoggingConfig::default();
assert_eq!(logging.level, "info");
assert!(logging.file.is_none());
}
#[test]
fn test_agent_config_default() {
let agent = AgentConfig::default();
assert_eq!(agent.approval_policy, "auto-always");
assert_eq!(agent.max_concurrent, 4);
}
#[test]
fn test_agent_config_from_toml() {
let toml_content = r#"
[agent]
approval_policy = "auto-always"
max_concurrent = 8
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.agent.approval_policy, "auto-always");
assert_eq!(config.agent.max_concurrent, 8);
}
#[test]
fn test_agent_config_defaults_when_absent() {
let toml_content = r#"
[logging]
level = "info"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.agent.approval_policy, "auto-always");
assert_eq!(config.agent.max_concurrent, 4);
}
#[test]
fn test_write_key_creates_and_updates() {
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(&config_path, "[logging]\nlevel = \"info\"\n").unwrap();
let content = fs::read_to_string(&config_path).unwrap();
let mut doc: toml::Value = toml::from_str(&content).unwrap();
let table = doc.as_table_mut().unwrap();
table.insert(
"agent".to_string(),
toml::Value::Table({
let mut m = toml::map::Map::new();
m.insert(
"approval_policy".to_string(),
toml::Value::String("auto-session".to_string()),
);
m
}),
);
let output = toml::to_string_pretty(&doc).unwrap();
fs::write(&config_path, &output).unwrap();
let content = fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&content).unwrap();
assert_eq!(loaded.agent.approval_policy, "auto-session");
assert_eq!(loaded.logging.level, "info");
}
#[test]
fn test_config_save_with_agent_section() {
let temp_file = NamedTempFile::new().unwrap();
let mut config = Config::default();
config.agent.approval_policy = "auto-always".to_string();
config.agent.max_concurrent = 2;
config.save(temp_file.path()).unwrap();
let contents = fs::read_to_string(temp_file.path()).unwrap();
let loaded: Config = toml::from_str(&contents).unwrap();
assert_eq!(loaded.agent.approval_policy, "auto-always");
assert_eq!(loaded.agent.max_concurrent, 2);
}
}
#[allow(clippy::items_after_test_module)]
pub fn resolve_provider_from_config(config: &Config) -> (&str, &str) {
for (_id, display, requires_api_key, cfg) in config.providers.provider_registry() {
if let Some(c) = cfg
&& c.enabled
&& (!requires_api_key || c.api_key.is_some())
{
let model = c.default_model.as_deref().unwrap_or("(default)");
return (display, model);
}
}
if let Some((name, cfg)) = config.providers.active_custom() {
let model = cfg.default_model.as_deref().unwrap_or("default");
return (name, model);
}
("Not configured", "N/A")
}