use std::path::{Path, PathBuf};
use chrono::Utc;
use rand::RngCore;
use crate::RoboticusConfig;
use crate::config::BackupsConfig;
#[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)
}
}
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))
}
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();
backups.sort();
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
}
});
}
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);
}
}
}
pub fn parse_and_validate_toml(content: &str) -> Result<RoboticusConfig, ConfigFileError> {
RoboticusConfig::from_str(content).map_err(|e| ConfigFileError::Validation(e.to_string()))
}
pub fn parse_and_validate_file(path: &Path) -> Result<RoboticusConfig, ConfigFileError> {
let content = std::fs::read_to_string(path)?;
parse_and_validate_toml(&content)
}
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
}
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))
}
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()));
}
}