forge_backup 1.2.0

A program to backup all the user home folders to an S3 bucket.
Documentation
use std::path::{Path, PathBuf};

use crate::{
    args::Args,
    error::{AppResult, ConfigError},
};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Debug, Default)]
pub struct Config {
    pub exclude_users: Vec<String>,
    pub exclude_files: Vec<String>,
    pub temp_folder: String,
    pub home_dir: String,

    pub hostname: String,

    pub aws_profile: Option<String>,
    pub s3_bucket: String,
    pub s3_folder: String,

    pub alert_email: String,
    pub notify_on_success: bool,

    pub mailgun_api_base: String,
    pub mailgun_api_key: String,
    pub mailgun_domain: String,
    pub sender_email: String,
}

macro_rules! update_field {
    ($arg:expr, $field:expr) => {
        if let Some(value) = $arg {
            $field = value;
        }
    };
}
impl std::fmt::Display for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match toml::to_string_pretty(self) {
            Ok(toml_string) => write!(f, "{toml_string}"),
            Err(_) => write!(f, "Failed to conver config to TOML"),
        }
    }
}

impl Config {
    pub fn load() -> AppResult<Self> {
        let config = get_config_path()?;
        let config = read_config_file(config)?;
        let config = substitute_env_vars(&config)?;
        parse_config(&config)
    }

    pub fn fallback() -> Self {
        Self {
            exclude_files: [
                "**/ai1m-backups/*".into(),
                "**/node_modules/*".into(),
                "**/.cache/*".into(),
                "**/.npm/*".into(),
                "**/vendor/*".into(),
            ]
            .to_vec(),
            home_dir: "/home".into(),
            temp_folder: "/tmp/forge-backups".into(),
            s3_folder: "daily".into(),
            hostname: hostname::get()
                .ok()
                .and_then(|h| h.to_str().map(|s| s.to_string()))
                .unwrap_or_default(),
            ..Default::default()
        }
    }

    pub fn update_from_args(&mut self, args: Args) {
        update_field!(args.temp_folder, self.temp_folder);
        if let Some(profile) = args.aws_profile {
            self.aws_profile = Some(profile);
        }
        update_field!(args.s3_folder, self.s3_folder);
        update_field!(args.s3_bucket, self.s3_bucket);
        update_field!(args.home_dir, self.home_dir);
        update_field!(args.alert_email, self.alert_email);
        update_field!(args.notify_success, self.notify_on_success);
        update_field!(args.exclude_users, self.exclude_users);
        update_field!(args.hostname, self.hostname);
    }
}

fn read_config_file<P: AsRef<Path>>(file: P) -> AppResult<String> {
    let file_path = file.as_ref().to_path_buf();

    let contents = std::fs::read_to_string(&file_path)
        .map_err(|_| ConfigError::FileNotFoundError(file_path.clone()))?;

    Ok(contents)
}

fn get_config_path() -> AppResult<PathBuf> {
    if let Some(proj_dirs) = ProjectDirs::from("dev", "Popplestones", "Forge-Backup") {
        let config_dir = proj_dirs.config_dir();
        Ok(config_dir.join("forge_backup.toml"))
    } else {
        Err(ConfigError::ProjectPathNotFoundError.into())
    }
}

fn parse_config(content: &str) -> AppResult<Config> {
    Ok(toml::from_str::<Config>(content)
        .map_err(|e| ConfigError::DeserializationError(content.into(), e))?)
}

fn substitute_env_vars(config_str: &str) -> AppResult<String> {
    fn helper(config_str: &str) -> AppResult<String> {
        if let Some(start) = config_str.find('$') {
            if let Some(end) = config_str[start..].find('}') {
                let end = start + end;
                if config_str.as_bytes()[start + 1] == b'{' {
                    let var_name = &config_str[start + 2..end];
                    if let Ok(env_value) = std::env::var(var_name) {
                        let before = &config_str[..start];
                        let after = &config_str[end + 1..];
                        let substituted = format!("{before}{env_value}{after}");
                        return helper(&substituted);
                    } else {
                        Err(ConfigError::EnvVarSubstitutionError(var_name.into()))?;
                    }
                }
            }
        }
        Ok(config_str.to_string())
    }
    helper(config_str)
}