1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use tokio::sync::RwLock;
8
9use roboticus_core::config_utils::prune_old_backups;
10use roboticus_core::{RoboticusConfig, home_dir};
11
12use crate::api::AppState;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ConfigApplyStatus {
16 pub config_path: String,
17 pub last_attempt_at: Option<String>,
18 pub last_success_at: Option<String>,
19 pub last_error: Option<String>,
20 pub last_backup_path: Option<String>,
21 pub deferred_apply: Vec<String>,
22}
23
24impl ConfigApplyStatus {
25 pub fn new(config_path: &Path) -> Self {
26 Self {
27 config_path: config_path.display().to_string(),
28 last_attempt_at: None,
29 last_success_at: None,
30 last_error: None,
31 last_backup_path: None,
32 deferred_apply: Vec::new(),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RuntimeApplyReport {
39 pub backup_path: Option<String>,
40 pub deferred_apply: Vec<String>,
41}
42
43#[derive(Debug, thiserror::Error)]
44pub enum ConfigRuntimeError {
45 #[error("I/O error: {0}")]
46 Io(#[from] std::io::Error),
47 #[error("TOML parse error: {0}")]
48 TomlDeserialize(#[from] toml::de::Error),
49 #[error("TOML serialize error: {0}")]
50 TomlSerialize(#[from] toml::ser::Error),
51 #[error("JSON serialize error: {0}")]
52 JsonSerialize(#[from] serde_json::Error),
53 #[error("validation failed: {0}")]
54 Validation(String),
55 #[error("config parent directory is missing for '{}'", .0.display())]
56 MissingParent(PathBuf),
57}
58
59impl From<ConfigRuntimeError> for roboticus_core::error::RoboticusError {
60 fn from(e: ConfigRuntimeError) -> Self {
61 Self::Config(e.to_string())
62 }
63}
64
65pub fn resolve_default_config_path() -> PathBuf {
66 let local = PathBuf::from("roboticus.toml");
67 if local.exists() {
68 return local;
69 }
70 let home_cfg = home_dir().join(".roboticus").join("roboticus.toml");
71 if home_cfg.exists() {
72 return home_cfg;
73 }
74 local
75}
76
77pub fn parse_and_validate_toml(content: &str) -> Result<RoboticusConfig, ConfigRuntimeError> {
78 RoboticusConfig::from_str(content).map_err(|e| ConfigRuntimeError::Validation(e.to_string()))
83}
84
85pub fn parse_and_validate_file(path: &Path) -> Result<RoboticusConfig, ConfigRuntimeError> {
86 let content = std::fs::read_to_string(path)?;
87 parse_and_validate_toml(&content)
88}
89
90pub fn backup_config_file(
91 path: &Path,
92 max_count: usize,
93 max_age_days: u32,
94) -> Result<Option<PathBuf>, ConfigRuntimeError> {
95 if !path.exists() {
96 return Ok(None);
97 }
98 let parent = path
99 .parent()
100 .ok_or_else(|| ConfigRuntimeError::MissingParent(path.to_path_buf()))?;
101 let backup_dir = parent.join("backups");
102 std::fs::create_dir_all(&backup_dir)?;
103 let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
104 let file_name = path
105 .file_name()
106 .and_then(|v| v.to_str())
107 .unwrap_or("roboticus.toml");
108 let backup_name = format!("{file_name}.bak.{stamp}");
109 let backup_path = backup_dir.join(backup_name);
110 std::fs::copy(path, &backup_path)?;
111 let prefix = format!("{file_name}.bak.");
112 prune_old_backups(&backup_dir, &prefix, max_count, max_age_days);
113 Ok(Some(backup_path))
114}
115
116fn normalize_backslash_paths(value: &mut Value) {
119 match value {
120 Value::String(s) => {
121 if s.contains('\\')
124 && (s.starts_with("C:\\") || s.starts_with("D:\\") || s.contains(":\\"))
125 {
126 *s = s.replace('\\', "/");
127 }
128 }
129 Value::Object(map) => {
130 for v in map.values_mut() {
131 normalize_backslash_paths(v);
132 }
133 }
134 Value::Array(arr) => {
135 for v in arr.iter_mut() {
136 normalize_backslash_paths(v);
137 }
138 }
139 _ => {}
140 }
141}
142
143pub fn write_config_atomic(path: &Path, cfg: &RoboticusConfig) -> Result<(), ConfigRuntimeError> {
144 let parent = path
145 .parent()
146 .ok_or_else(|| ConfigRuntimeError::MissingParent(path.to_path_buf()))?;
147 std::fs::create_dir_all(parent)?;
148 let mut json_val = serde_json::to_value(cfg).map_err(ConfigRuntimeError::JsonSerialize)?;
153 normalize_backslash_paths(&mut json_val);
154 let normalized: RoboticusConfig =
155 serde_json::from_value(json_val).map_err(ConfigRuntimeError::JsonSerialize)?;
156 let content = toml::to_string_pretty(&normalized)?;
157 let tmp_name = format!(
158 ".{}.tmp.{}",
159 path.file_name()
160 .and_then(|v| v.to_str())
161 .unwrap_or("roboticus"),
162 uuid::Uuid::new_v4()
163 );
164 let tmp_path = parent.join(tmp_name);
165 std::fs::write(&tmp_path, content)?;
166 std::fs::rename(&tmp_path, path)?;
167 Ok(())
168}
169
170pub fn restore_from_backup(path: &Path, backup_path: &Path) -> Result<(), ConfigRuntimeError> {
171 let content = std::fs::read(backup_path)?;
172 std::fs::write(path, content)?;
173 Ok(())
174}
175
176pub fn merge_patch(base: &mut Value, patch: &Value) {
177 match (base, patch) {
178 (Value::Object(base_map), Value::Object(patch_map)) => {
179 for (k, v) in patch_map {
180 let entry = base_map.entry(k.clone()).or_insert(Value::Null);
181 merge_patch(entry, v);
182 }
183 }
184 (base, patch) => {
185 *base = patch.clone();
186 }
187 }
188}
189
190pub async fn apply_runtime_config(
191 state: &AppState,
192 updated: RoboticusConfig,
193) -> Result<RuntimeApplyReport, ConfigRuntimeError> {
194 let config_path = state.config_path.as_ref().clone();
195 let old_config = state.config.read().await.clone();
196 let backup_path = backup_config_file(
197 &config_path,
198 old_config.backups.max_count,
199 old_config.backups.max_age_days,
200 )?;
201 write_config_atomic(&config_path, &updated)?;
202
203 let deferred_apply = vec![
207 "server.bind".to_string(),
208 "server.port".to_string(),
209 "wallet".to_string(),
210 ];
211
212 let apply_result: Result<(), ConfigRuntimeError> = async {
213 {
215 let mut config = state.config.write().await;
216 *config = updated.clone();
217 }
218 {
220 let mut llm = state.llm.write().await;
221 llm.router.sync_runtime(
222 updated.models.primary.clone(),
223 updated.models.fallbacks.clone(),
224 updated.models.routing.clone(),
225 );
226 llm.breakers.sync_config(&updated.circuit_breaker);
227 }
228 {
230 let mut a2a = state.a2a.write().await;
231 a2a.config = updated.a2a.clone();
232 }
233 state.reload_personality().await;
235 Ok(())
236 }
237 .await;
238
239 if let Err(err) = apply_result {
240 if let Some(ref backup) = backup_path
241 && let Err(e) = restore_from_backup(&config_path, backup)
242 {
243 tracing::error!(error = %e, path = %config_path.display(), "failed to restore config from backup — config file may be corrupted");
244 }
245 {
246 let mut config = state.config.write().await;
247 *config = old_config;
248 }
249 return Err(err);
250 }
251
252 Ok(RuntimeApplyReport {
253 backup_path: backup_path.map(|p| p.display().to_string()),
254 deferred_apply,
255 })
256}
257
258pub fn config_value_from_file_or_runtime(
259 path: &Path,
260 runtime_cfg: &RoboticusConfig,
261) -> Result<Value, ConfigRuntimeError> {
262 if path.exists() {
263 let parsed = parse_and_validate_file(path)?;
264 return Ok(serde_json::to_value(parsed)?);
265 }
266 Ok(serde_json::to_value(runtime_cfg)?)
267}
268
269pub fn status_for_path(path: &Path) -> Arc<RwLock<ConfigApplyStatus>> {
270 Arc::new(RwLock::new(ConfigApplyStatus::new(path)))
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn test_config() -> &'static str {
278 r#"
279[agent]
280name = "Test"
281id = "test"
282
283[server]
284port = 18789
285
286[database]
287path = ":memory:"
288
289[models]
290primary = "ollama/qwen3:8b"
291"#
292 }
293
294 #[test]
295 fn parse_and_validate_toml_accepts_valid_content() {
296 let cfg = parse_and_validate_toml(test_config()).expect("valid config");
297 assert_eq!(cfg.agent.id, "test");
298 }
299
300 #[test]
301 fn parse_and_validate_toml_rejects_invalid_content() {
302 let err = parse_and_validate_toml("[agent]\nname = 1").expect_err("must fail");
303 assert!(err.to_string().contains("TOML"));
304 }
305
306 #[test]
307 fn backup_config_file_creates_timestamped_backup() {
308 let dir = tempfile::tempdir().expect("tempdir");
309 let path = dir.path().join("roboticus.toml");
310 std::fs::write(&path, test_config()).expect("seed config");
311 let backup = backup_config_file(&path, 10, 30)
312 .expect("backup ok")
313 .expect("backup path");
314 assert!(backup.exists());
315 let name = backup.file_name().and_then(|v| v.to_str()).unwrap_or("");
316 assert!(name.starts_with("roboticus.toml.bak."));
317 assert!(backup.parent().unwrap().ends_with("backups"));
319 }
320
321 #[test]
322 fn write_config_atomic_persists_toml() {
323 let dir = tempfile::tempdir().expect("tempdir");
324 let path = dir.path().join("roboticus.toml");
325 let cfg = parse_and_validate_toml(test_config()).expect("parse");
326 write_config_atomic(&path, &cfg).expect("write");
327 let written = std::fs::read_to_string(path).expect("read");
328 assert!(written.contains("[agent]"));
329 assert!(written.contains("primary = \"ollama/qwen3:8b\""));
330 }
331
332 #[test]
333 fn restore_from_backup_restores_original_content() {
334 let dir = tempfile::tempdir().expect("tempdir");
335 let path = dir.path().join("roboticus.toml");
336 let backup_path = dir.path().join("roboticus.toml.bak");
337
338 let original = "original-content";
339 let overwritten = "overwritten-content";
340
341 std::fs::write(&path, original).expect("seed original");
342 std::fs::write(&backup_path, original).expect("seed backup");
343 std::fs::write(&path, overwritten).expect("overwrite");
344
345 assert_eq!(std::fs::read_to_string(&path).unwrap(), overwritten);
346
347 restore_from_backup(&path, &backup_path).expect("restore");
348 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
349 }
350
351 #[test]
352 fn merge_patch_deep_merge_objects() {
353 let mut base = serde_json::json!({"a": {"inner": 1, "keep": true}});
354 merge_patch(
355 &mut base,
356 &serde_json::json!({"a": {"inner": 99, "new": "val"}}),
357 );
358 assert_eq!(base["a"]["inner"], 99);
359 assert_eq!(base["a"]["keep"], true);
360 assert_eq!(base["a"]["new"], "val");
361 }
362
363 #[test]
364 fn merge_patch_replaces_scalar() {
365 let mut base = serde_json::json!({"key": "old"});
366 merge_patch(&mut base, &serde_json::json!({"key": "new"}));
367 assert_eq!(base["key"], "new");
368 }
369
370 #[test]
371 fn merge_patch_adds_new_keys() {
372 let mut base = serde_json::json!({"existing": 1});
373 merge_patch(&mut base, &serde_json::json!({"added": 2}));
374 assert_eq!(base["existing"], 1);
375 assert_eq!(base["added"], 2);
376 }
377
378 #[test]
379 fn merge_patch_replaces_array() {
380 let mut base = serde_json::json!({"arr": [1, 2, 3]});
381 merge_patch(&mut base, &serde_json::json!({"arr": [4, 5]}));
382 assert_eq!(base["arr"], serde_json::json!([4, 5]));
383 }
384
385 #[test]
386 fn merge_patch_replaces_scalar_with_object() {
387 let mut base = serde_json::json!({"val": "string"});
388 merge_patch(&mut base, &serde_json::json!({"val": {"nested": true}}));
389 assert_eq!(base["val"]["nested"], true);
390 }
391
392 #[test]
393 fn config_value_from_file_or_runtime_uses_file_when_it_exists() {
394 let dir = tempfile::tempdir().expect("tempdir");
395 let path = dir.path().join("roboticus.toml");
396 std::fs::write(&path, test_config()).expect("seed config");
397
398 let runtime_cfg = parse_and_validate_toml(test_config()).expect("parse");
399 let val = config_value_from_file_or_runtime(&path, &runtime_cfg).expect("read");
400 assert_eq!(val["agent"]["id"], "test");
401 }
402
403 #[test]
404 fn config_value_from_file_or_runtime_uses_runtime_when_no_file() {
405 let path = std::path::PathBuf::from("/nonexistent/roboticus.toml");
406 let runtime_cfg = parse_and_validate_toml(test_config()).expect("parse");
407 let val = config_value_from_file_or_runtime(&path, &runtime_cfg).expect("read");
408 assert_eq!(val["agent"]["id"], "test");
409 }
410
411 #[test]
412 fn config_runtime_error_display_variants() {
413 let io_err = ConfigRuntimeError::Io(std::io::Error::new(
414 std::io::ErrorKind::NotFound,
415 "file not found",
416 ));
417 assert!(io_err.to_string().contains("I/O error"));
418
419 let validation_err = ConfigRuntimeError::Validation("bad field".into());
420 assert!(validation_err.to_string().contains("validation failed"));
421
422 let missing_parent = ConfigRuntimeError::MissingParent(PathBuf::from("/some/path"));
423 assert!(
424 missing_parent
425 .to_string()
426 .contains("config parent directory is missing")
427 );
428 }
429
430 #[test]
431 fn config_runtime_error_from_io() {
432 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
433 let err: ConfigRuntimeError = io_err.into();
434 assert!(err.to_string().contains("I/O error"));
435 }
436
437 #[test]
438 fn config_apply_status_new_initializes_empty() {
439 let status = ConfigApplyStatus::new(std::path::Path::new("/tmp/test.toml"));
440 assert_eq!(status.config_path, "/tmp/test.toml");
441 assert!(status.last_attempt_at.is_none());
442 assert!(status.last_success_at.is_none());
443 assert!(status.last_error.is_none());
444 assert!(status.last_backup_path.is_none());
445 assert!(status.deferred_apply.is_empty());
446 }
447
448 #[test]
449 fn status_for_path_returns_arc_rwlock() {
450 let arc = status_for_path(std::path::Path::new("/tmp/status.toml"));
451 let rt = tokio::runtime::Runtime::new().unwrap();
452 let status = rt.block_on(arc.read());
453 assert_eq!(status.config_path, "/tmp/status.toml");
454 }
455
456 #[test]
457 fn backup_config_file_returns_none_for_missing_file() {
458 let dir = tempfile::tempdir().expect("tempdir");
459 let path = dir.path().join("does_not_exist.toml");
460 let result = backup_config_file(&path, 10, 30).expect("ok");
461 assert!(result.is_none());
462 }
463
464 #[test]
465 fn parse_and_validate_file_works_for_valid_file() {
466 let dir = tempfile::tempdir().expect("tempdir");
467 let path = dir.path().join("roboticus.toml");
468 std::fs::write(&path, test_config()).expect("seed config");
469 let cfg = parse_and_validate_file(&path).expect("parse");
470 assert_eq!(cfg.agent.id, "test");
471 }
472
473 #[test]
474 fn parse_and_validate_file_errors_for_missing_file() {
475 let err = parse_and_validate_file(std::path::Path::new("/nonexistent/file.toml"));
476 assert!(err.is_err());
477 }
478
479 #[test]
480 fn prune_old_backups_keeps_newest_by_count() {
481 let dir = tempfile::tempdir().unwrap();
482 let backup_dir = dir.path().join("backups");
483 std::fs::create_dir_all(&backup_dir).unwrap();
484
485 for i in 0..15 {
487 let name = format!("test.toml.bak.20260301T12{i:02}00.000Z");
488 std::fs::write(backup_dir.join(&name), "").unwrap();
489 }
490
491 prune_old_backups(&backup_dir, "test.toml.bak.", 10, 0);
493
494 let remaining: Vec<_> = std::fs::read_dir(&backup_dir)
495 .unwrap()
496 .filter_map(|e| e.ok())
497 .filter(|e| e.file_name().to_str().unwrap_or("").contains(".bak."))
498 .collect();
499
500 assert_eq!(remaining.len(), 10);
501 }
502
503 #[test]
504 fn prune_old_backups_removes_old_by_age() {
505 let dir = tempfile::tempdir().unwrap();
506 let backup_dir = dir.path().join("backups");
507 std::fs::create_dir_all(&backup_dir).unwrap();
508
509 let old_date = (Utc::now() - chrono::Duration::days(60))
512 .format("%Y%m%dT%H%M%S%.3fZ")
513 .to_string();
514 let new_date = Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
515
516 for i in 0..5 {
517 let name = format!("cfg.toml.bak.{old_date}{i}");
519 std::fs::write(backup_dir.join(&name), "").unwrap();
520 }
521 for i in 0..5 {
522 let name = format!("cfg.toml.bak.{new_date}{i}");
523 std::fs::write(backup_dir.join(&name), "").unwrap();
524 }
525
526 prune_old_backups(&backup_dir, "cfg.toml.bak.", 0, 30);
528
529 let remaining: Vec<_> = std::fs::read_dir(&backup_dir)
530 .unwrap()
531 .filter_map(|e| e.ok())
532 .filter(|e| e.file_name().to_str().unwrap_or("").contains(".bak."))
533 .collect();
534
535 assert_eq!(remaining.len(), 5, "only recent backups should remain");
536 }
537
538 #[test]
539 fn prune_old_backups_both_criteria() {
540 let dir = tempfile::tempdir().unwrap();
541 let backup_dir = dir.path().join("backups");
542 std::fs::create_dir_all(&backup_dir).unwrap();
543
544 for i in 0..12 {
546 let name = format!("t.toml.bak.20260315T00{i:02}00.000Z");
547 std::fs::write(backup_dir.join(&name), "").unwrap();
548 }
549
550 prune_old_backups(&backup_dir, "t.toml.bak.", 5, 30);
551
552 let remaining: Vec<_> = std::fs::read_dir(&backup_dir)
553 .unwrap()
554 .filter_map(|e| e.ok())
555 .filter(|e| e.file_name().to_str().unwrap_or("").contains(".bak."))
556 .collect();
557
558 assert_eq!(remaining.len(), 5);
559 }
560}