use crate::error::AppError;
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub r2: R2Config,
pub local: LocalConfig,
pub reaper: ReaperConfig,
pub identity: IdentityConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct R2Config {
pub account_id: String,
pub access_key_id: String,
pub secret_access_key: String,
pub bucket: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalConfig {
pub working_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReaperConfig {
pub binary_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityConfig {
pub user: String,
pub machine: String,
}
pub fn config_path() -> PathBuf {
home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("whirlwind")
.join("config.toml")
}
impl Config {
pub fn load() -> Result<Config, AppError> {
let path = config_path();
Self::load_from_path(&path)
}
pub fn load_from_path(path: &Path) -> Result<Config, AppError> {
let contents = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::ConfigMissing
} else {
AppError::ConfigInvalid(format!("could not read {}: {}", path.display(), e))
}
})?;
toml::from_str(&contents).map_err(|e| {
AppError::ConfigInvalid(format!("TOML parse error in {}: {}", path.display(), e))
})
}
pub fn save(&self) -> Result<(), AppError> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
AppError::ConfigInvalid(format!(
"could not create config directory {}: {}",
parent.display(),
e
))
})?;
}
let toml_str = toml::to_string_pretty(self)
.map_err(|e| AppError::ConfigInvalid(format!("failed to serialize config: {}", e)))?;
std::fs::write(&path, &toml_str).map_err(|e| {
AppError::ConfigInvalid(format!(
"could not write config to {}: {}",
path.display(),
e
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, permissions).map_err(|e| {
AppError::ConfigInvalid(format!(
"could not set permissions on {}: {}",
path.display(),
e
))
})?;
}
Ok(())
}
pub fn validate(&self) -> Result<(), AppError> {
if self.r2.account_id.is_empty() {
return Err(AppError::ConfigInvalid(
"r2.account_id is empty".to_string(),
));
}
if self.r2.access_key_id.is_empty() {
return Err(AppError::ConfigInvalid(
"r2.access_key_id is empty".to_string(),
));
}
if self.r2.secret_access_key.is_empty() {
return Err(AppError::ConfigInvalid(
"r2.secret_access_key is empty".to_string(),
));
}
if self.r2.bucket.is_empty() {
return Err(AppError::ConfigInvalid("r2.bucket is empty".to_string()));
}
if self.local.working_dir == Path::new("") {
return Err(AppError::ConfigInvalid(
"local.working_dir is empty".to_string(),
));
}
if self.identity.user.is_empty() {
return Err(AppError::ConfigInvalid(
"identity.user is empty".to_string(),
));
}
if self.identity.machine.is_empty() {
return Err(AppError::ConfigInvalid(
"identity.machine is empty".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> Config {
Config {
r2: R2Config {
account_id: "abc123def456".to_string(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
bucket: "podcast-projects".to_string(),
},
local: LocalConfig {
working_dir: PathBuf::from("/Users/alice/podcast"),
},
reaper: ReaperConfig {
binary_path: PathBuf::from("/Applications/REAPER.app/Contents/MacOS/REAPER"),
},
identity: IdentityConfig {
user: "alice".to_string(),
machine: "alice-macbook".to_string(),
},
}
}
#[test]
fn config_round_trips_through_toml() {
let original = sample_config();
let toml_str = toml::to_string_pretty(&original).expect("serialize failed");
let restored: Config = toml::from_str(&toml_str).expect("deserialize failed");
assert_eq!(restored.r2.account_id, original.r2.account_id);
assert_eq!(restored.r2.access_key_id, original.r2.access_key_id);
assert_eq!(restored.r2.secret_access_key, original.r2.secret_access_key);
assert_eq!(restored.r2.bucket, original.r2.bucket);
assert_eq!(restored.local.working_dir, original.local.working_dir);
assert_eq!(restored.reaper.binary_path, original.reaper.binary_path);
assert_eq!(restored.identity.user, original.identity.user);
assert_eq!(restored.identity.machine, original.identity.machine);
}
#[test]
fn config_path_contains_whirlwind() {
let path = config_path();
assert!(
path.to_string_lossy().contains("whirlwind"),
"expected 'whirlwind' in config path, got: {}",
path.display()
);
}
#[test]
fn validate_fails_on_empty_user() {
let mut config = sample_config();
config.identity.user = String::new();
let result = config.validate();
assert!(
result.is_err(),
"expected validate() to fail with empty identity.user"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("identity.user"),
"expected error message to mention 'identity.user', got: {msg}"
);
}
#[test]
fn validate_fails_on_empty_account_id() {
let mut config = sample_config();
config.r2.account_id = String::new();
let result = config.validate();
assert!(
result.is_err(),
"expected validate() to fail with empty r2.account_id"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("r2.account_id"),
"expected error message to mention 'r2.account_id', got: {msg}"
);
}
#[test]
fn load_missing_file_returns_config_missing() {
let result = Config::load_from_path(&PathBuf::from("/nonexistent/path/config.toml"));
assert!(
result.is_err(),
"expected an error loading from a nonexistent path"
);
let err = result.unwrap_err();
assert!(
matches!(err, crate::error::AppError::ConfigMissing),
"expected ConfigMissing, got: {:?}",
err
);
}
}