attune-core 0.1.0

Core traits and types for attune: runtime-mutable, persisted, observable configuration.
Documentation
use std::{
    env, fs,
    path::{Path, PathBuf},
};

use toml::Table;

use crate::SettingsError;

pub struct ConfigPathOptions<'a> {
    pub app: &'a str,
    pub path: Option<&'a str>,
    pub default_file: Option<&'a str>,
}

/// Resolves the path to an optional config file.
///
/// Resolution order:
///
/// 1. If `<APP>_CONFIG` is set, its value is used as an override.
/// 2. If `options.path` is set, that path is returned unchanged.
/// 3. If `options.default_file` is set, it is resolved under the platform
///    config directory.
/// 4. Otherwise, no config path is resolved.
///
/// This function only resolves a path. It does not check whether the file
/// exists or read the file.
///
/// ## Errors
///
/// Returns [`SettingsError::NoConfigDir`] when `options.default_file` is set
/// but the platform config directory cannot be discovered.
pub fn resolve_config_file(options: &ConfigPathOptions) -> Result<Option<PathBuf>, SettingsError> {
    // 1. Try to resolve the config path from environment variables.
    if let Ok(env_var) = env::var(format!("{}_CONFIG", options.app.to_uppercase())) {
        return Ok(Some(PathBuf::from(env_var)));
    }

    // 2. Use the configured path unchanged.
    if let Some(path) = options.path {
        return Ok(Some(Path::new(path).to_path_buf()));
    }

    // 3. If there is no configured path or default filename, there is no
    // optional config file to resolve.
    let Some(default_file) = options.default_file else {
        return Ok(None);
    };

    // 4. Try to determine the default path from project directories.
    let Some(project_dirs) = directories::ProjectDirs::from("", "", options.app) else {
        return Err(SettingsError::NoConfigDir);
    };

    Ok(Some(project_dirs.config_dir().join(default_file)))
}

/// Loads an optional TOML config file into a table.
///
/// Returns `Ok(None)` when `path` is `None` or when the file does not exist.
/// Returns `Ok(Some(table))` when the file exists and parses successfully.
///
/// ## Errors
///
/// Returns [`SettingsError::ConfigRead`] when the file exists but cannot be
/// read. Returns [`SettingsError::ConfigParse`] when the file contents are not
/// valid TOML.
pub fn load_toml_file(path: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
    match path {
        Some(file) => {
            let raw_content = match fs::read_to_string(file) {
                Ok(content) => content,
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
                Err(e) => {
                    return Err(SettingsError::ConfigRead {
                        path: file.to_path_buf(),
                        source: e,
                    });
                }
            };

            let parsed_content =
                raw_content
                    .parse::<Table>()
                    .map_err(|e| SettingsError::ConfigParse {
                        path: file.to_path_buf(),
                        source: e,
                    })?;

            Ok(Some(parsed_content))
        }
        None => Ok(None),
    }
}

#[cfg(test)]
mod test {
    use std::io::Write;

    use std::assert_matches;
    use toml::Value;

    use super::*;

    #[test]
    fn test_resolve_config_file_from_configured_path() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let tmp_path = tmp.into_temp_path();
        let tmp_path_str = tmp_path.to_str().unwrap().to_string();

        let options = ConfigPathOptions {
            app: "test_app",
            path: Some(&tmp_path_str),
            default_file: None,
        };

        let config_file = resolve_config_file(&options).unwrap().unwrap();
        assert_eq!(config_file, PathBuf::from(tmp_path_str))
    }

    #[test]
    fn test_resolve_config_file_from_env_var() {
        let config_path = "~/.config/test_app/test.toml";
        let options = ConfigPathOptions {
            app: "test_app",
            path: None,
            default_file: None,
        };

        temp_env::with_var("TEST_APP_CONFIG", Some(config_path), || {
            let config_file = resolve_config_file(&options);
            assert!(config_file.is_ok());

            let config_file = config_file.unwrap().unwrap();
            assert_eq!(config_file.to_str().unwrap(), config_path)
        });
    }

    #[test]
    fn test_resolve_config_file_returns_none_without_path_or_default() {
        let options = ConfigPathOptions {
            app: "test_app_without_path_or_default",
            path: None,
            default_file: None,
        };

        let config_file = resolve_config_file(&options).unwrap();
        assert!(config_file.is_none())
    }

    #[test]
    fn test_load_toml_file_returns_table() {
        let content = "foo = 'bar'";
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(content.as_bytes()).unwrap();

        let parsed = load_toml_file(Some(tmp.path())).unwrap().unwrap();
        assert_eq!(parsed["foo"], Value::from("bar"))
    }

    #[test]
    fn test_load_toml_file_returns_none_for_missing_file() {
        let parsed = load_toml_file(Some(Path::new("not/real/path"))).unwrap();
        assert!(parsed.is_none())
    }

    #[test]
    fn test_load_toml_file_returns_none_when_given_none() {
        let parsed = load_toml_file(None).unwrap();
        assert!(parsed.is_none())
    }

    #[test]
    fn test_load_toml_file_returns_settingserror_for_invalid_toml() {
        let content = "aaabbbccc";
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        tmp.write_all(content.as_bytes()).unwrap();

        let err = load_toml_file(Some(tmp.path())).unwrap_err();
        assert_matches!(err, SettingsError::ConfigParse { .. })
    }
}