Skip to main content

roboticus_api/
config_runtime.rs

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    // Delegate to RoboticusConfig::from_str which runs normalize_paths(),
79    // merge_bundled_providers(), and validate() — matching the startup path.
80    // Without this, hot-reloaded configs would have raw ~ paths and missing
81    // bundled providers.
82    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
116/// Recursively normalize Windows backslash paths to forward slashes in JSON
117/// string values that look like filesystem paths.
118fn normalize_backslash_paths(value: &mut Value) {
119    match value {
120        Value::String(s) => {
121            // Heuristic: looks like a Windows absolute path (e.g., C:\Users\...)
122            // or contains backslash-separated segments that resemble paths.
123            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    // BUG-031: Normalize Windows backslash paths to forward slashes before
149    // TOML serialization.  TOML basic strings treat `\U` as a unicode
150    // escape, which breaks `C:\Users\...` paths.  Round-trip through JSON
151    // to normalize path-like string values.
152    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    // Only settings that genuinely require a process restart belong here.
204    // server.bind/port: requires rebinding the TCP listener socket.
205    // wallet: holds crypto keys + chain state; partial swap risks fund loss.
206    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        // Core config swap — all subsequent reads see the new config.
214        {
215            let mut config = state.config.write().await;
216            *config = updated.clone();
217        }
218        // LLM routing: primary/fallback chain, routing mode, timeout budgets.
219        {
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        // A2A protocol config.
229        {
230            let mut a2a = state.a2a.write().await;
231            a2a.config = updated.a2a.clone();
232        }
233        // Personality: agent name, persona, tone — already behind RwLock.
234        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        // Verify backup is in the backups/ subdirectory
318        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        // Create 15 backups with lexicographically ordered timestamps.
486        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        // max_age_days=0 disables age pruning; only count-based.
492        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        // Create backups: 5 from 60 days ago (should be pruned with max_age_days=30)
510        // and 5 from today (should be kept).
511        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            // Old backups — tweak last digit so they're unique.
518            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        // max_count=0 disables count pruning; only age-based (30 days).
527        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        // Create 12 recent backups — count pruning should reduce to 5.
545        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}