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