use super::*;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn opencrabs_home() -> PathBuf {
let p = crate::config::profile::resolve_profile_home();
if !p.exists()
&& let Err(e) = std::fs::create_dir_all(&p)
{
tracing::error!("Failed to create opencrabs home directory {p:?}: {e}");
}
p
}
pub fn daily_backup(path: &Path, max_days: usize) {
if !path.exists() {
return;
}
let parent = match path.parent() {
Some(p) => p,
None => return,
};
let stem = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let today_backup = parent.join(format!("{stem}.{today}.bak"));
if today_backup.exists() {
return;
}
if let Err(e) = fs::copy(path, &today_backup) {
tracing::warn!("Failed to back up {} before write: {e}", path.display());
return;
}
tracing::debug!("Daily backup: {}", today_backup.display());
let prefix = format!("{stem}.");
let suffix = ".bak";
if let Ok(entries) = fs::read_dir(parent) {
let mut backups: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && name.ends_with(suffix) && name != stem {
Some(name)
} else {
None
}
})
.collect();
backups.sort();
backups.reverse(); for old in backups.iter().skip(max_days) {
let _ = fs::remove_file(parent.join(old));
tracing::debug!("Pruned old backup: {old}");
}
}
}
pub fn save_last_good_config() {
let home = opencrabs_home();
let config_path = home.join("config.toml");
let keys_path_src = home.join("keys.toml");
let config_good = home.join("config.last_good.toml");
let keys_good = home.join("keys.last_good.toml");
if config_path.exists() {
if let Err(e) = Config::load_from_path(&config_path) {
tracing::warn!("Refusing last-good snapshot: config.toml does not parse: {e}");
return;
}
if let Err(e) = fs::copy(&config_path, &config_good) {
tracing::debug!("Failed to save last-good config: {e}");
}
}
if keys_path_src.exists()
&& let Err(e) = fs::copy(&keys_path_src, &keys_good)
{
tracing::debug!("Failed to save last-good keys: {e}");
}
}
pub fn load_last_good_config() -> Option<Config> {
let home = opencrabs_home();
let config_good = home.join("config.last_good.toml");
if !config_good.exists() {
return None;
}
tracing::warn!("Attempting recovery from last-known-good config");
if let Ok(meta) = fs::metadata(&config_good)
&& let Ok(modified) = meta.modified()
{
let age = std::time::SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
let saved_at = chrono::DateTime::<chrono::Local>::from(modified)
.format("%Y-%m-%d %H:%M:%S")
.to_string();
let hours = age.as_secs() / 3600;
let age_str = if hours >= 24 {
format!("{}d {}h", hours / 24, hours % 24)
} else if hours >= 1 {
format!("{}h {}m", hours, (age.as_secs() % 3600) / 60)
} else {
format!("{}m", age.as_secs() / 60)
};
tracing::warn!(
"last_known_good snapshot is {} old (saved at {}) — \
config.toml changes since then are silently ignored",
age_str,
saved_at
);
}
let mut config = match Config::load_from_path(&config_good) {
Ok(c) => c,
Err(e) => {
tracing::error!("Last-good config also failed: {e}");
return None;
}
};
let keys_good = home.join("keys.last_good.toml");
if keys_good.exists()
&& let Ok(content) = fs::read_to_string(&keys_good)
&& let Ok(keys) = toml::from_str::<KeysFile>(&content)
{
config.providers = merge_provider_keys(config.providers, keys.providers);
config.channels = merge_channel_keys(config.channels, keys.channels);
}
tracing::warn!("Recovered config from last-known-good snapshot");
Some(config)
}
pub fn keys_path() -> PathBuf {
opencrabs_home().join("keys.toml")
}
pub(crate) fn raw_config_custom_provider_names() -> std::collections::HashSet<String> {
use toml_edit::DocumentMut;
let path = Config::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
let Ok(content) = std::fs::read_to_string(&path) else {
return std::collections::HashSet::new();
};
let Ok(doc) = content.parse::<DocumentMut>() else {
return std::collections::HashSet::new();
};
doc.as_table()
.get("providers")
.and_then(|t| t.as_table())
.and_then(|t| t.get("custom"))
.and_then(|t| t.as_table())
.map(|t| t.iter().map(|(k, _)| k.to_string()).collect())
.unwrap_or_default()
}
pub fn save_keys(keys: &ProviderConfigs) -> Result<()> {
let providers: &[(&str, Option<&ProviderConfig>)] = &[
("providers.anthropic", keys.anthropic.as_ref()),
("providers.openai", keys.openai.as_ref()),
("providers.openrouter", keys.openrouter.as_ref()),
("providers.minimax", keys.minimax.as_ref()),
("providers.gemini", keys.gemini.as_ref()),
];
for (section, provider) in providers {
if let Some(p) = provider
&& let Some(key) = &p.api_key
&& !key.is_empty()
{
write_secret_key(section, "api_key", key)?;
}
}
if let Some(customs) = &keys.custom {
for (name, p) in customs {
if let Some(key) = &p.api_key
&& !key.is_empty()
{
let section = if name == "default" {
"providers.custom".to_string()
} else {
format!("providers.custom.{}", name)
};
write_secret_key(§ion, "api_key", key)?;
}
}
}
tracing::info!("Saved API keys to: {:?}", keys_path());
Ok(())
}
pub fn normalize_toml_key(key: &str) -> String {
key.trim()
.to_lowercase()
.replace(['.', '_', ' '], "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()
.trim_matches('-')
.to_string()
}
pub fn write_secret_key(section: &str, key: &str, value: &str) -> Result<()> {
use toml_edit::DocumentMut;
let value = value.split(['\r', '\n']).next().unwrap_or("").trim();
if value.is_empty() {
return Ok(()); }
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = keys_path();
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))?;
}
current.insert(key, toml_edit::value(value));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
daily_backup(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!("Wrote secret key [{section}].{key}");
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeysFile {
#[serde(default)]
pub providers: ProviderConfigs,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub a2a: Option<KeysA2a>,
#[serde(default)]
pub image: Option<ImageKeys>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageKeys {
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeysA2a {
pub api_key: Option<String>,
}
pub(crate) fn load_keys_from_file() -> Result<KeysFile> {
let keys_path = keys_path();
if !keys_path.exists() {
return Ok(KeysFile::default());
}
tracing::trace!("Loading keys from: {:?}", keys_path);
let content = std::fs::read_to_string(&keys_path)?;
let keys: KeysFile = toml::from_str(&content)?;
Ok(keys)
}
pub(crate) fn merge_provider_keys(
mut base: ProviderConfigs,
keys: ProviderConfigs,
) -> ProviderConfigs {
let is_real_key = |k: &str| !k.is_empty() && k != "__EXISTING_KEY__";
if let Some(k) = keys.anthropic
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.anthropic.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.openai
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.openai.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.openrouter
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.openrouter.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
tracing::trace!(
"merge_provider_keys: minimax keys present={}, base present={}",
keys.minimax.is_some(),
base.minimax.is_some()
);
if let Some(k) = keys.minimax
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.minimax.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.xiaomi
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.xiaomi.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.gemini
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.gemini.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.github
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.github.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.zhipu
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.zhipu.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.qwen
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.qwen.get_or_insert_with(|| ProviderConfig {
enabled: true,
..Default::default()
});
entry.api_key = Some(key);
if entry.default_model.is_none() && k.default_model.is_some() {
entry.default_model = k.default_model;
}
if entry.base_url.is_none() && k.base_url.is_some() {
entry.base_url = k.base_url;
}
}
if let Some(k) = keys.opencode
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.opencode.get_or_insert_with(|| ProviderConfig {
enabled: true,
..Default::default()
});
entry.api_key = Some(key);
if entry.default_model.is_none() && k.default_model.is_some() {
entry.default_model = k.default_model;
}
if entry.base_url.is_none() && k.base_url.is_some() {
entry.base_url = k.base_url;
}
}
if let Some(custom_keys) = keys.custom {
let base_customs = base.custom.get_or_insert_with(BTreeMap::default);
for (name, key_cfg) in custom_keys {
if let Some(key) = key_cfg.api_key
&& is_real_key(&key)
{
use std::collections::btree_map::Entry;
match base_customs.entry(name.clone()) {
Entry::Occupied(mut occupied) => {
tracing::trace!(
"merge_provider_keys: merging api_key for custom '{}'",
name
);
occupied.get_mut().api_key = Some(key);
}
Entry::Vacant(vacant) => {
tracing::trace!(
"merge_provider_keys: custom '{}' has key in keys.toml but no config.toml entry — creating minimal entry",
name
);
vacant.insert(ProviderConfig {
api_key: Some(key),
base_url: key_cfg.base_url,
default_model: key_cfg.default_model,
..Default::default()
});
}
}
}
}
}
if let Some(stt) = keys.stt
&& let Some(groq) = stt.groq
&& let Some(key) = groq.api_key
{
let base_stt = base.stt.get_or_insert_with(SttProviders::default);
let entry = base_stt.groq.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(tts) = keys.tts
&& let Some(openai) = tts.openai
&& let Some(key) = openai.api_key
{
let base_tts = base.tts.get_or_insert_with(TtsProviders::default);
let entry = base_tts.openai.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(ws) = keys.web_search {
let base_ws = base
.web_search
.get_or_insert_with(WebSearchProviders::default);
if let Some(exa) = ws.exa
&& let Some(key) = exa.api_key
&& !key.is_empty()
{
let entry = base_ws.exa.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(brave) = ws.brave
&& let Some(key) = brave.api_key
&& !key.is_empty()
{
let entry = base_ws.brave.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
}
if let Some(img) = keys.image {
let base_img = base.image.get_or_insert_with(ImageProviders::default);
if let Some(gemini) = img.gemini
&& let Some(key) = gemini.api_key
&& !key.is_empty()
{
let entry = base_img.gemini.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
}
if let Some(ref customs) = base.custom {
let total = customs.len();
let with_key = customs
.values()
.filter(|c| {
c.api_key
.as_ref()
.is_some_and(|k| !k.is_empty() && k != "__EXISTING_KEY__")
})
.count();
let missing: Vec<&str> = customs
.iter()
.filter(|(_, c)| {
!c.api_key
.as_ref()
.is_some_and(|k| !k.is_empty() && k != "__EXISTING_KEY__")
})
.map(|(n, _)| n.as_str())
.collect();
tracing::trace!(
"merge_provider_keys: custom providers loaded = {} ({} with real api_key); \
providers missing a real key: {:?}",
total,
with_key,
missing,
);
}
base
}
pub(crate) fn merge_channel_keys(mut base: ChannelsConfig, keys: ChannelsConfig) -> ChannelsConfig {
if let Some(ref token) = keys.telegram.token
&& !token.is_empty()
{
base.telegram.token = Some(token.clone());
}
if let Some(ref token) = keys.discord.token
&& !token.is_empty()
{
base.discord.token = Some(token.clone());
}
if let Some(ref token) = keys.slack.token
&& !token.is_empty()
{
base.slack.token = Some(token.clone());
}
if let Some(ref app_token) = keys.slack.app_token
&& !app_token.is_empty()
{
base.slack.app_token = Some(app_token.clone());
}
if let Some(ref app_token) = keys.trello.app_token
&& !app_token.is_empty()
{
base.trello.app_token = Some(app_token.clone());
}
if let Some(ref token) = keys.trello.token
&& !token.is_empty()
{
base.trello.token = Some(token.clone());
}
base
}