roboticus-core 0.11.1

Shared types, config parsing, personality system, and error types for the Roboticus agent runtime
Documentation
//! Pure utility functions for config file management (backup, validation).
//!
//! These live in `roboticus-core` so that both `roboticus-cli` and `roboticus-api`
//! can use them without creating a circular dependency.

use std::path::{Path, PathBuf};

use chrono::Utc;
use rand::RngCore;

use crate::RoboticusConfig;
use crate::config::BackupsConfig;

/// Error type for config file operations.
#[derive(Debug)]
pub enum ConfigFileError {
    Io(std::io::Error),
    TomlDeserialize(toml::de::Error),
    Validation(String),
    MissingParent(PathBuf),
}

impl std::fmt::Display for ConfigFileError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(e) => write!(f, "I/O error: {e}"),
            Self::TomlDeserialize(e) => write!(f, "TOML parse error: {e}"),
            Self::Validation(e) => write!(f, "validation failed: {e}"),
            Self::MissingParent(p) => {
                write!(
                    f,
                    "config parent directory is missing for '{}'",
                    p.display()
                )
            }
        }
    }
}

impl std::error::Error for ConfigFileError {}

impl From<std::io::Error> for ConfigFileError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

impl From<toml::de::Error> for ConfigFileError {
    fn from(value: toml::de::Error) -> Self {
        Self::TomlDeserialize(value)
    }
}

/// Creates a timestamped backup of a config file. Returns `None` if the file
/// does not exist.  Backups are stored in a `backups/` subdirectory next to the
/// config file, and old backups are pruned according to `max_count` and
/// `max_age_days`.
pub fn backup_config_file(
    path: &Path,
    max_count: usize,
    max_age_days: u32,
) -> Result<Option<PathBuf>, ConfigFileError> {
    if !path.exists() {
        return Ok(None);
    }
    let parent = path
        .parent()
        .ok_or_else(|| ConfigFileError::MissingParent(path.to_path_buf()))?;
    let backup_dir = parent.join("backups");
    std::fs::create_dir_all(&backup_dir)?;
    let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
    let file_name = path
        .file_name()
        .and_then(|v| v.to_str())
        .unwrap_or("roboticus.toml");
    let backup_name = format!("{file_name}.bak.{stamp}");
    let backup_path = backup_dir.join(backup_name);
    std::fs::copy(path, &backup_path)?;
    let prefix = format!("{file_name}.bak.");
    prune_old_backups(&backup_dir, &prefix, max_count, max_age_days);
    Ok(Some(backup_path))
}

/// Remove old config backups by count and age.
///
/// - `max_count = 0`: skip count-based pruning (only age-based).
/// - `max_age_days = 0`: skip age-based pruning (only count-based).
fn prune_old_backups(backup_dir: &Path, prefix: &str, max_count: usize, max_age_days: u32) {
    let mut backups: Vec<PathBuf> = std::fs::read_dir(backup_dir)
        .into_iter()
        .flatten()
        .filter_map(|e| e.ok())
        .filter(|e| {
            e.file_name()
                .to_str()
                .is_some_and(|name| name.starts_with(prefix))
        })
        .map(|e| e.path())
        .collect();

    // Sort by name ascending -- the timestamp suffix is ISO-8601 so
    // lexicographic order == chronological order (oldest first).
    backups.sort();

    // Age-based pruning: remove backups older than max_age_days.
    if max_age_days > 0 {
        let cutoff = Utc::now() - chrono::Duration::days(i64::from(max_age_days));
        let cutoff_stamp = cutoff.format("%Y%m%dT%H%M%S%.3fZ").to_string();
        backups.retain(|p| {
            let dominated_by_age = p
                .file_name()
                .and_then(|f| f.to_str())
                .and_then(|name| name.strip_prefix(prefix))
                .is_some_and(|ts| ts < cutoff_stamp.as_str());
            if dominated_by_age {
                let _ = std::fs::remove_file(p);
                false
            } else {
                true
            }
        });
    }

    // Count-based pruning: keep only the newest max_count.
    if max_count > 0 && backups.len() > max_count {
        let to_remove = backups.len() - max_count;
        for path in backups.into_iter().take(to_remove) {
            let _ = std::fs::remove_file(&path);
        }
    }
}

/// Parses and validates a TOML config string.
pub fn parse_and_validate_toml(content: &str) -> Result<RoboticusConfig, ConfigFileError> {
    RoboticusConfig::from_str(content).map_err(|e| ConfigFileError::Validation(e.to_string()))
}

/// Parses and validates a config file from disk.
pub fn parse_and_validate_file(path: &Path) -> Result<RoboticusConfig, ConfigFileError> {
    let content = std::fs::read_to_string(path)?;
    parse_and_validate_toml(&content)
}

/// Resolves the default config file path, checking `./roboticus.toml` first,
/// then `~/.roboticus/roboticus.toml`.
pub fn resolve_default_config_path() -> PathBuf {
    let local = PathBuf::from("roboticus.toml");
    if local.exists() {
        return local;
    }
    let home_cfg = crate::home_dir().join(".roboticus").join("roboticus.toml");
    if home_cfg.exists() {
        return home_cfg;
    }
    local
}

/// Migrate removed legacy config keys, backing up the original first.
pub fn migrate_removed_legacy_config_file(
    path: &Path,
) -> Result<Option<crate::config::ConfigMigrationReport>, Box<dyn std::error::Error>> {
    if !path.exists() {
        return Ok(None);
    }

    let raw = std::fs::read_to_string(path)?;
    let Some((rewritten, report)) = crate::config::migrate_removed_legacy_config(&raw)? else {
        return Ok(None);
    };

    let defaults = BackupsConfig::default();
    backup_config_file(path, defaults.max_count, defaults.max_age_days)?;
    let tmp = path.with_extension("toml.tmp");
    std::fs::write(&tmp, rewritten)?;
    std::fs::rename(&tmp, path)?;
    Ok(Some(report))
}

/// Cryptographically random value for `[server] api_key` in `roboticus.toml`.
///
/// Format: `rk_` + 32 random bytes as hex (64 hex digits). Safe for TOML double-quoted strings.
pub fn generate_server_api_key() -> String {
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
    format!("rk_{hex}")
}

#[cfg(test)]
mod generate_key_tests {
    use super::generate_server_api_key;

    #[test]
    fn server_api_key_format_and_uniqueness() {
        let a = generate_server_api_key();
        let b = generate_server_api_key();
        assert!(a.starts_with("rk_"));
        assert!(b.starts_with("rk_"));
        assert_eq!(a.len(), 3 + 64);
        assert_eq!(b.len(), 3 + 64);
        assert_ne!(a, b);
        let hex_a = a.strip_prefix("rk_").expect("rk_ prefix");
        assert!(hex_a.chars().all(|c| c.is_ascii_hexdigit()));
    }
}