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::{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    // Delegate to RoboticusConfig::from_str which runs normalize_paths(),
78    // merge_bundled_providers(), and validate() — matching the startup path.
79    // Without this, hot-reloaded configs would have raw ~ paths and missing
80    // bundled providers.
81    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
115/// Remove old config backups by count and age.
116///
117/// - `max_count = 0`: skip count-based pruning (only age-based).
118/// - `max_age_days = 0`: skip age-based pruning (only count-based).
119fn 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    // Sort by name ascending — the timestamp suffix is ISO-8601 so
133    // lexicographic order == chronological order (oldest first).
134    backups.sort();
135
136    // Age-based pruning: remove backups older than max_age_days.
137    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    // Count-based pruning: keep only the newest max_count.
156    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
164/// Recursively normalize Windows backslash paths to forward slashes in JSON
165/// string values that look like filesystem paths.
166fn normalize_backslash_paths(value: &mut Value) {
167    match value {
168        Value::String(s) => {
169            // Heuristic: looks like a Windows absolute path (e.g., C:\Users\...)
170            // or contains backslash-separated segments that resemble paths.
171            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    // BUG-031: Normalize Windows backslash paths to forward slashes before
197    // TOML serialization.  TOML basic strings treat `\U` as a unicode
198    // escape, which breaks `C:\Users\...` paths.  Round-trip through JSON
199    // to normalize path-like string values.
200    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    // Only settings that genuinely require a process restart belong here.
252    // server.bind/port: requires rebinding the TCP listener socket.
253    // wallet: holds crypto keys + chain state; partial swap risks fund loss.
254    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        // Core config swap — all subsequent reads see the new config.
262        {
263            let mut config = state.config.write().await;
264            *config = updated.clone();
265        }
266        // LLM routing: primary/fallback chain, routing mode, timeout budgets.
267        {
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        // A2A protocol config.
277        {
278            let mut a2a = state.a2a.write().await;
279            a2a.config = updated.a2a.clone();
280        }
281        // Personality: agent name, persona, tone — already behind RwLock.
282        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        // Verify backup is in the backups/ subdirectory
366        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        // Create 15 backups with lexicographically ordered timestamps.
534        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        // max_age_days=0 disables age pruning; only count-based.
540        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        // Create backups: 5 from 60 days ago (should be pruned with max_age_days=30)
558        // and 5 from today (should be kept).
559        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            // Old backups — tweak last digit so they're unique.
566            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        // max_count=0 disables count pruning; only age-based (30 days).
575        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        // Create 12 recent backups — count pruning should reduce to 5.
593        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}