Skip to main content

roboticus_core/
config_utils.rs

1//! Pure utility functions for config file management (backup, validation).
2//!
3//! These live in `roboticus-core` so that both `roboticus-cli` and `roboticus-api`
4//! can use them without creating a circular dependency.
5
6use std::path::{Path, PathBuf};
7
8use chrono::Utc;
9
10use crate::RoboticusConfig;
11use crate::config::BackupsConfig;
12
13/// Error type for config file operations.
14#[derive(Debug)]
15pub enum ConfigFileError {
16    Io(std::io::Error),
17    TomlDeserialize(toml::de::Error),
18    Validation(String),
19    MissingParent(PathBuf),
20}
21
22impl std::fmt::Display for ConfigFileError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            Self::Io(e) => write!(f, "I/O error: {e}"),
26            Self::TomlDeserialize(e) => write!(f, "TOML parse error: {e}"),
27            Self::Validation(e) => write!(f, "validation failed: {e}"),
28            Self::MissingParent(p) => {
29                write!(
30                    f,
31                    "config parent directory is missing for '{}'",
32                    p.display()
33                )
34            }
35        }
36    }
37}
38
39impl std::error::Error for ConfigFileError {}
40
41impl From<std::io::Error> for ConfigFileError {
42    fn from(value: std::io::Error) -> Self {
43        Self::Io(value)
44    }
45}
46
47impl From<toml::de::Error> for ConfigFileError {
48    fn from(value: toml::de::Error) -> Self {
49        Self::TomlDeserialize(value)
50    }
51}
52
53/// Creates a timestamped backup of a config file. Returns `None` if the file
54/// does not exist.  Backups are stored in a `backups/` subdirectory next to the
55/// config file, and old backups are pruned according to `max_count` and
56/// `max_age_days`.
57pub fn backup_config_file(
58    path: &Path,
59    max_count: usize,
60    max_age_days: u32,
61) -> Result<Option<PathBuf>, ConfigFileError> {
62    if !path.exists() {
63        return Ok(None);
64    }
65    let parent = path
66        .parent()
67        .ok_or_else(|| ConfigFileError::MissingParent(path.to_path_buf()))?;
68    let backup_dir = parent.join("backups");
69    std::fs::create_dir_all(&backup_dir)?;
70    let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
71    let file_name = path
72        .file_name()
73        .and_then(|v| v.to_str())
74        .unwrap_or("roboticus.toml");
75    let backup_name = format!("{file_name}.bak.{stamp}");
76    let backup_path = backup_dir.join(backup_name);
77    std::fs::copy(path, &backup_path)?;
78    let prefix = format!("{file_name}.bak.");
79    prune_old_backups(&backup_dir, &prefix, max_count, max_age_days);
80    Ok(Some(backup_path))
81}
82
83/// Remove old config backups by count and age.
84///
85/// - `max_count = 0`: skip count-based pruning (only age-based).
86/// - `max_age_days = 0`: skip age-based pruning (only count-based).
87fn prune_old_backups(backup_dir: &Path, prefix: &str, max_count: usize, max_age_days: u32) {
88    let mut backups: Vec<PathBuf> = std::fs::read_dir(backup_dir)
89        .into_iter()
90        .flatten()
91        .filter_map(|e| e.ok())
92        .filter(|e| {
93            e.file_name()
94                .to_str()
95                .is_some_and(|name| name.starts_with(prefix))
96        })
97        .map(|e| e.path())
98        .collect();
99
100    // Sort by name ascending -- the timestamp suffix is ISO-8601 so
101    // lexicographic order == chronological order (oldest first).
102    backups.sort();
103
104    // Age-based pruning: remove backups older than max_age_days.
105    if max_age_days > 0 {
106        let cutoff = Utc::now() - chrono::Duration::days(i64::from(max_age_days));
107        let cutoff_stamp = cutoff.format("%Y%m%dT%H%M%S%.3fZ").to_string();
108        backups.retain(|p| {
109            let dominated_by_age = p
110                .file_name()
111                .and_then(|f| f.to_str())
112                .and_then(|name| name.strip_prefix(prefix))
113                .is_some_and(|ts| ts < cutoff_stamp.as_str());
114            if dominated_by_age {
115                let _ = std::fs::remove_file(p);
116                false
117            } else {
118                true
119            }
120        });
121    }
122
123    // Count-based pruning: keep only the newest max_count.
124    if max_count > 0 && backups.len() > max_count {
125        let to_remove = backups.len() - max_count;
126        for path in backups.into_iter().take(to_remove) {
127            let _ = std::fs::remove_file(&path);
128        }
129    }
130}
131
132/// Parses and validates a TOML config string.
133pub fn parse_and_validate_toml(content: &str) -> Result<RoboticusConfig, ConfigFileError> {
134    RoboticusConfig::from_str(content).map_err(|e| ConfigFileError::Validation(e.to_string()))
135}
136
137/// Parses and validates a config file from disk.
138pub fn parse_and_validate_file(path: &Path) -> Result<RoboticusConfig, ConfigFileError> {
139    let content = std::fs::read_to_string(path)?;
140    parse_and_validate_toml(&content)
141}
142
143/// Resolves the default config file path, checking `./roboticus.toml` first,
144/// then `~/.roboticus/roboticus.toml`.
145pub fn resolve_default_config_path() -> PathBuf {
146    let local = PathBuf::from("roboticus.toml");
147    if local.exists() {
148        return local;
149    }
150    let home_cfg = crate::home_dir().join(".roboticus").join("roboticus.toml");
151    if home_cfg.exists() {
152        return home_cfg;
153    }
154    local
155}
156
157/// Migrate removed legacy config keys, backing up the original first.
158pub fn migrate_removed_legacy_config_file(
159    path: &Path,
160) -> Result<Option<crate::config::ConfigMigrationReport>, Box<dyn std::error::Error>> {
161    if !path.exists() {
162        return Ok(None);
163    }
164
165    let raw = std::fs::read_to_string(path)?;
166    let Some((rewritten, report)) = crate::config::migrate_removed_legacy_config(&raw)? else {
167        return Ok(None);
168    };
169
170    let defaults = BackupsConfig::default();
171    backup_config_file(path, defaults.max_count, defaults.max_age_days)?;
172    let tmp = path.with_extension("toml.tmp");
173    std::fs::write(&tmp, rewritten)?;
174    std::fs::rename(&tmp, path)?;
175    Ok(Some(report))
176}