use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
use ironclad_core::{IroncladConfig, home_dir};
use crate::api::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigApplyStatus {
pub config_path: String,
pub last_attempt_at: Option<String>,
pub last_success_at: Option<String>,
pub last_error: Option<String>,
pub last_backup_path: Option<String>,
pub deferred_apply: Vec<String>,
}
impl ConfigApplyStatus {
pub fn new(config_path: &Path) -> Self {
Self {
config_path: config_path.display().to_string(),
last_attempt_at: None,
last_success_at: None,
last_error: None,
last_backup_path: None,
deferred_apply: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeApplyReport {
pub backup_path: Option<String>,
pub deferred_apply: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigRuntimeError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
TomlDeserialize(#[from] toml::de::Error),
#[error("TOML serialize error: {0}")]
TomlSerialize(#[from] toml::ser::Error),
#[error("JSON serialize error: {0}")]
JsonSerialize(#[from] serde_json::Error),
#[error("validation failed: {0}")]
Validation(String),
#[error("config parent directory is missing for '{}'", .0.display())]
MissingParent(PathBuf),
}
impl From<ConfigRuntimeError> for ironclad_core::error::IroncladError {
fn from(e: ConfigRuntimeError) -> Self {
Self::Config(e.to_string())
}
}
pub fn resolve_default_config_path() -> PathBuf {
let local = PathBuf::from("ironclad.toml");
if local.exists() {
return local;
}
let home_cfg = home_dir().join(".ironclad").join("ironclad.toml");
if home_cfg.exists() {
return home_cfg;
}
local
}
pub fn parse_and_validate_toml(content: &str) -> Result<IroncladConfig, ConfigRuntimeError> {
IroncladConfig::from_str(content).map_err(|e| ConfigRuntimeError::Validation(e.to_string()))
}
pub fn parse_and_validate_file(path: &Path) -> Result<IroncladConfig, ConfigRuntimeError> {
let content = std::fs::read_to_string(path)?;
parse_and_validate_toml(&content)
}
pub fn backup_config_file(
path: &Path,
max_count: usize,
max_age_days: u32,
) -> Result<Option<PathBuf>, ConfigRuntimeError> {
if !path.exists() {
return Ok(None);
}
let parent = path
.parent()
.ok_or_else(|| ConfigRuntimeError::MissingParent(path.to_path_buf()))?;
let backup_dir = parent.join("backups");
std::fs::create_dir_all(&backup_dir)?;
let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let file_name = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("ironclad.toml");
let backup_name = format!("{file_name}.bak.{stamp}");
let backup_path = backup_dir.join(backup_name);
std::fs::copy(path, &backup_path)?;
let prefix = format!("{file_name}.bak.");
prune_old_backups(&backup_dir, &prefix, max_count, max_age_days);
Ok(Some(backup_path))
}
fn prune_old_backups(backup_dir: &Path, prefix: &str, max_count: usize, max_age_days: u32) {
let mut backups: Vec<PathBuf> = std::fs::read_dir(backup_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|name| name.starts_with(prefix))
})
.map(|e| e.path())
.collect();
backups.sort();
if max_age_days > 0 {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(max_age_days));
let cutoff_stamp = cutoff.format("%Y%m%dT%H%M%S%.3fZ").to_string();
backups.retain(|p| {
let dominated_by_age = p
.file_name()
.and_then(|f| f.to_str())
.and_then(|name| name.strip_prefix(prefix))
.is_some_and(|ts| ts < cutoff_stamp.as_str());
if dominated_by_age {
let _ = std::fs::remove_file(p);
false
} else {
true
}
});
}
if max_count > 0 && backups.len() > max_count {
let to_remove = backups.len() - max_count;
for path in backups.into_iter().take(to_remove) {
let _ = std::fs::remove_file(&path);
}
}
}
fn normalize_backslash_paths(value: &mut Value) {
match value {
Value::String(s) => {
if s.contains('\\')
&& (s.starts_with("C:\\") || s.starts_with("D:\\") || s.contains(":\\"))
{
*s = s.replace('\\', "/");
}
}
Value::Object(map) => {
for v in map.values_mut() {
normalize_backslash_paths(v);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
normalize_backslash_paths(v);
}
}
_ => {}
}
}
pub fn write_config_atomic(path: &Path, cfg: &IroncladConfig) -> Result<(), ConfigRuntimeError> {
let parent = path
.parent()
.ok_or_else(|| ConfigRuntimeError::MissingParent(path.to_path_buf()))?;
std::fs::create_dir_all(parent)?;
let mut json_val = serde_json::to_value(cfg).map_err(ConfigRuntimeError::JsonSerialize)?;
normalize_backslash_paths(&mut json_val);
let normalized: IroncladConfig =
serde_json::from_value(json_val).map_err(ConfigRuntimeError::JsonSerialize)?;
let content = toml::to_string_pretty(&normalized)?;
let tmp_name = format!(
".{}.tmp.{}",
path.file_name()
.and_then(|v| v.to_str())
.unwrap_or("ironclad"),
uuid::Uuid::new_v4()
);
let tmp_path = parent.join(tmp_name);
std::fs::write(&tmp_path, content)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn restore_from_backup(path: &Path, backup_path: &Path) -> Result<(), ConfigRuntimeError> {
let content = std::fs::read(backup_path)?;
std::fs::write(path, content)?;
Ok(())
}
pub fn merge_patch(base: &mut Value, patch: &Value) {
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
for (k, v) in patch_map {
let entry = base_map.entry(k.clone()).or_insert(Value::Null);
merge_patch(entry, v);
}
}
(base, patch) => {
*base = patch.clone();
}
}
}
pub async fn apply_runtime_config(
state: &AppState,
updated: IroncladConfig,
) -> Result<RuntimeApplyReport, ConfigRuntimeError> {
let config_path = state.config_path.as_ref().clone();
let old_config = state.config.read().await.clone();
let backup_path = backup_config_file(
&config_path,
old_config.backups.max_count,
old_config.backups.max_age_days,
)?;
write_config_atomic(&config_path, &updated)?;
let deferred_apply = vec![
"server.bind".to_string(),
"server.port".to_string(),
"wallet".to_string(),
];
let apply_result: Result<(), ConfigRuntimeError> = async {
{
let mut config = state.config.write().await;
*config = updated.clone();
}
{
let mut llm = state.llm.write().await;
llm.router.sync_runtime(
updated.models.primary.clone(),
updated.models.fallbacks.clone(),
updated.models.routing.clone(),
);
llm.breakers.sync_config(&updated.circuit_breaker);
}
{
let mut a2a = state.a2a.write().await;
a2a.config = updated.a2a.clone();
}
state.reload_personality().await;
Ok(())
}
.await;
if let Err(err) = apply_result {
if let Some(ref backup) = backup_path
&& let Err(e) = restore_from_backup(&config_path, backup)
{
tracing::error!(error = %e, path = %config_path.display(), "failed to restore config from backup — config file may be corrupted");
}
{
let mut config = state.config.write().await;
*config = old_config;
}
return Err(err);
}
Ok(RuntimeApplyReport {
backup_path: backup_path.map(|p| p.display().to_string()),
deferred_apply,
})
}
pub fn config_value_from_file_or_runtime(
path: &Path,
runtime_cfg: &IroncladConfig,
) -> Result<Value, ConfigRuntimeError> {
if path.exists() {
let parsed = parse_and_validate_file(path)?;
return Ok(serde_json::to_value(parsed)?);
}
Ok(serde_json::to_value(runtime_cfg)?)
}
pub fn status_for_path(path: &Path) -> Arc<RwLock<ConfigApplyStatus>> {
Arc::new(RwLock::new(ConfigApplyStatus::new(path)))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> &'static str {
r#"
[agent]
name = "Test"
id = "test"
[server]
port = 18789
[database]
path = ":memory:"
[models]
primary = "ollama/qwen3:8b"
"#
}
#[test]
fn parse_and_validate_toml_accepts_valid_content() {
let cfg = parse_and_validate_toml(test_config()).expect("valid config");
assert_eq!(cfg.agent.id, "test");
}
#[test]
fn parse_and_validate_toml_rejects_invalid_content() {
let err = parse_and_validate_toml("[agent]\nname = 1").expect_err("must fail");
assert!(err.to_string().contains("TOML"));
}
#[test]
fn backup_config_file_creates_timestamped_backup() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ironclad.toml");
std::fs::write(&path, test_config()).expect("seed config");
let backup = backup_config_file(&path, 10, 30)
.expect("backup ok")
.expect("backup path");
assert!(backup.exists());
let name = backup.file_name().and_then(|v| v.to_str()).unwrap_or("");
assert!(name.starts_with("ironclad.toml.bak."));
assert!(backup.parent().unwrap().ends_with("backups"));
}
#[test]
fn write_config_atomic_persists_toml() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ironclad.toml");
let cfg = parse_and_validate_toml(test_config()).expect("parse");
write_config_atomic(&path, &cfg).expect("write");
let written = std::fs::read_to_string(path).expect("read");
assert!(written.contains("[agent]"));
assert!(written.contains("primary = \"ollama/qwen3:8b\""));
}
#[test]
fn restore_from_backup_restores_original_content() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ironclad.toml");
let backup_path = dir.path().join("ironclad.toml.bak");
let original = "original-content";
let overwritten = "overwritten-content";
std::fs::write(&path, original).expect("seed original");
std::fs::write(&backup_path, original).expect("seed backup");
std::fs::write(&path, overwritten).expect("overwrite");
assert_eq!(std::fs::read_to_string(&path).unwrap(), overwritten);
restore_from_backup(&path, &backup_path).expect("restore");
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn merge_patch_deep_merge_objects() {
let mut base = serde_json::json!({"a": {"inner": 1, "keep": true}});
merge_patch(
&mut base,
&serde_json::json!({"a": {"inner": 99, "new": "val"}}),
);
assert_eq!(base["a"]["inner"], 99);
assert_eq!(base["a"]["keep"], true);
assert_eq!(base["a"]["new"], "val");
}
#[test]
fn merge_patch_replaces_scalar() {
let mut base = serde_json::json!({"key": "old"});
merge_patch(&mut base, &serde_json::json!({"key": "new"}));
assert_eq!(base["key"], "new");
}
#[test]
fn merge_patch_adds_new_keys() {
let mut base = serde_json::json!({"existing": 1});
merge_patch(&mut base, &serde_json::json!({"added": 2}));
assert_eq!(base["existing"], 1);
assert_eq!(base["added"], 2);
}
#[test]
fn merge_patch_replaces_array() {
let mut base = serde_json::json!({"arr": [1, 2, 3]});
merge_patch(&mut base, &serde_json::json!({"arr": [4, 5]}));
assert_eq!(base["arr"], serde_json::json!([4, 5]));
}
#[test]
fn merge_patch_replaces_scalar_with_object() {
let mut base = serde_json::json!({"val": "string"});
merge_patch(&mut base, &serde_json::json!({"val": {"nested": true}}));
assert_eq!(base["val"]["nested"], true);
}
#[test]
fn config_value_from_file_or_runtime_uses_file_when_it_exists() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ironclad.toml");
std::fs::write(&path, test_config()).expect("seed config");
let runtime_cfg = parse_and_validate_toml(test_config()).expect("parse");
let val = config_value_from_file_or_runtime(&path, &runtime_cfg).expect("read");
assert_eq!(val["agent"]["id"], "test");
}
#[test]
fn config_value_from_file_or_runtime_uses_runtime_when_no_file() {
let path = std::path::PathBuf::from("/nonexistent/ironclad.toml");
let runtime_cfg = parse_and_validate_toml(test_config()).expect("parse");
let val = config_value_from_file_or_runtime(&path, &runtime_cfg).expect("read");
assert_eq!(val["agent"]["id"], "test");
}
#[test]
fn config_runtime_error_display_variants() {
let io_err = ConfigRuntimeError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
));
assert!(io_err.to_string().contains("I/O error"));
let validation_err = ConfigRuntimeError::Validation("bad field".into());
assert!(validation_err.to_string().contains("validation failed"));
let missing_parent = ConfigRuntimeError::MissingParent(PathBuf::from("/some/path"));
assert!(
missing_parent
.to_string()
.contains("config parent directory is missing")
);
}
#[test]
fn config_runtime_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let err: ConfigRuntimeError = io_err.into();
assert!(err.to_string().contains("I/O error"));
}
#[test]
fn config_apply_status_new_initializes_empty() {
let status = ConfigApplyStatus::new(std::path::Path::new("/tmp/test.toml"));
assert_eq!(status.config_path, "/tmp/test.toml");
assert!(status.last_attempt_at.is_none());
assert!(status.last_success_at.is_none());
assert!(status.last_error.is_none());
assert!(status.last_backup_path.is_none());
assert!(status.deferred_apply.is_empty());
}
#[test]
fn status_for_path_returns_arc_rwlock() {
let arc = status_for_path(std::path::Path::new("/tmp/status.toml"));
let rt = tokio::runtime::Runtime::new().unwrap();
let status = rt.block_on(arc.read());
assert_eq!(status.config_path, "/tmp/status.toml");
}
#[test]
fn backup_config_file_returns_none_for_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("does_not_exist.toml");
let result = backup_config_file(&path, 10, 30).expect("ok");
assert!(result.is_none());
}
#[test]
fn parse_and_validate_file_works_for_valid_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ironclad.toml");
std::fs::write(&path, test_config()).expect("seed config");
let cfg = parse_and_validate_file(&path).expect("parse");
assert_eq!(cfg.agent.id, "test");
}
#[test]
fn parse_and_validate_file_errors_for_missing_file() {
let err = parse_and_validate_file(std::path::Path::new("/nonexistent/file.toml"));
assert!(err.is_err());
}
#[test]
fn prune_old_backups_keeps_newest_by_count() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
std::fs::create_dir_all(&backup_dir).unwrap();
for i in 0..15 {
let name = format!("test.toml.bak.20260301T12{i:02}00.000Z");
std::fs::write(backup_dir.join(&name), "").unwrap();
}
prune_old_backups(&backup_dir, "test.toml.bak.", 10, 0);
let remaining: Vec<_> = std::fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_str().unwrap_or("").contains(".bak."))
.collect();
assert_eq!(remaining.len(), 10);
}
#[test]
fn prune_old_backups_removes_old_by_age() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
std::fs::create_dir_all(&backup_dir).unwrap();
let old_date = (Utc::now() - chrono::Duration::days(60))
.format("%Y%m%dT%H%M%S%.3fZ")
.to_string();
let new_date = Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
for i in 0..5 {
let name = format!("cfg.toml.bak.{old_date}{i}");
std::fs::write(backup_dir.join(&name), "").unwrap();
}
for i in 0..5 {
let name = format!("cfg.toml.bak.{new_date}{i}");
std::fs::write(backup_dir.join(&name), "").unwrap();
}
prune_old_backups(&backup_dir, "cfg.toml.bak.", 0, 30);
let remaining: Vec<_> = std::fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_str().unwrap_or("").contains(".bak."))
.collect();
assert_eq!(remaining.len(), 5, "only recent backups should remain");
}
#[test]
fn prune_old_backups_both_criteria() {
let dir = tempfile::tempdir().unwrap();
let backup_dir = dir.path().join("backups");
std::fs::create_dir_all(&backup_dir).unwrap();
for i in 0..12 {
let name = format!("t.toml.bak.20260315T00{i:02}00.000Z");
std::fs::write(backup_dir.join(&name), "").unwrap();
}
prune_old_backups(&backup_dir, "t.toml.bak.", 5, 30);
let remaining: Vec<_> = std::fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_str().unwrap_or("").contains(".bak."))
.collect();
assert_eq!(remaining.len(), 5);
}
}