use anyhow::{Context, Result};
use serde::Deserialize;
use std::{fs, path::PathBuf};
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AppConfig {
pub filter_command: String,
pub directories: Vec<PathBuf>,
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
filter_command: "fzf --ansi --layout=reverse --border=rounded --height=50%".to_string(),
directories: Vec::new(),
}
}
}
pub fn load_app_config() -> Result<AppConfig> {
let config_path = {
#[cfg(target_os = "macos")]
let base = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".config");
#[cfg(not(target_os = "macos"))]
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
base.join("cmdy").join("cmdy.toml")
};
if config_path.is_file() {
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
match toml::from_str::<AppConfig>(&content) {
Ok(cfg) => return Ok(cfg),
Err(e) => eprintln!(
"Warning: Failed to parse config file {}: {}. Using defaults.",
config_path.display(),
e
),
}
}
Ok(AppConfig::default())
}
pub fn determine_config_directory(cli_dir_flag: &Option<PathBuf>) -> Result<PathBuf> {
if let Some(dir) = cli_dir_flag {
return Ok(dir.clone());
}
#[cfg(target_os = "macos")]
let base = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".config");
#[cfg(not(target_os = "macos"))]
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
let path = base.join("cmdy").join("commands");
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{env, fs, path::PathBuf, sync::Mutex};
use tempfile::tempdir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_determine_config_directory_flag_override() -> Result<()> {
let _guard = ENV_LOCK.lock().unwrap();
let temp_dir = tempdir()?;
let flag_path = temp_dir.path().join("custom_cmdy_dir_test");
let cli_dir = Some(flag_path.clone());
let result = determine_config_directory(&cli_dir)?;
assert_eq!(result, flag_path);
Ok(())
}
#[test]
fn test_determine_config_directory_default() -> Result<()> {
let _guard = ENV_LOCK.lock().unwrap();
let cli_dir = None;
let result = determine_config_directory(&cli_dir)?;
let expected = if cfg!(target_os = "macos") {
env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".config")
.join("cmdy")
.join("commands")
} else {
env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join("cmdy")
.join("commands")
};
assert_eq!(result, expected);
Ok(())
}
#[test]
fn test_load_app_config_default() -> Result<()> {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
env::remove_var("XDG_CONFIG_HOME");
}
unsafe {
env::remove_var("HOME");
}
let cfg = load_app_config()?;
let default = AppConfig::default();
assert_eq!(cfg.filter_command, default.filter_command);
assert!(cfg.directories.is_empty());
Ok(())
}
#[test]
fn test_load_app_config_file_parsed() -> Result<()> {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir()?;
let (_config_dir, config_file) = if cfg!(target_os = "macos") {
unsafe {
env::set_var("HOME", tmp.path());
}
let base = tmp.path().join(".config").join("cmdy");
fs::create_dir_all(&base)?;
let file = base.join("cmdy.toml");
(base, file)
} else {
unsafe {
env::set_var("XDG_CONFIG_HOME", tmp.path());
}
let base = tmp.path().join("cmdy");
fs::create_dir_all(&base)?;
let file = base.join("cmdy.toml");
(base, file)
};
let content = r#"
filter_command = "TESTCMD"
directories = ["one", "two"]
"#;
fs::write(&config_file, content)?;
let cfg = load_app_config()?;
assert_eq!(cfg.filter_command, "TESTCMD");
assert_eq!(
cfg.directories,
vec![PathBuf::from("one"), PathBuf::from("two")]
);
Ok(())
}
#[test]
fn test_load_app_config_invalid_toml() -> Result<()> {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir()?;
if cfg!(target_os = "macos") {
unsafe {
env::set_var("HOME", tmp.path());
}
let base = tmp.path().join(".config").join("cmdy");
fs::create_dir_all(&base)?;
fs::write(base.join("cmdy.toml"), "not toml")?;
} else {
unsafe {
env::set_var("XDG_CONFIG_HOME", tmp.path());
}
let base = tmp.path().join("cmdy");
fs::create_dir_all(&base)?;
fs::write(base.join("cmdy.toml"), "not toml")?;
}
let cfg = load_app_config()?;
let default = AppConfig::default();
assert_eq!(cfg.filter_command, default.filter_command);
assert!(cfg.directories.is_empty());
Ok(())
}
#[test]
#[cfg(unix)]
fn test_load_app_config_io_error() -> Result<()> {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir()?;
#[cfg(target_os = "macos")]
unsafe {
env::set_var("HOME", tmp.path());
}
#[cfg(not(target_os = "macos"))]
unsafe {
env::set_var("XDG_CONFIG_HOME", tmp.path());
}
#[cfg(target_os = "macos")]
let base = tmp.path().join(".config").join("cmdy");
#[cfg(not(target_os = "macos"))]
let base = tmp.path().join("cmdy");
fs::create_dir_all(&base)?;
let cfg_file = base.join("cmdy.toml");
fs::write(&cfg_file, "filter_command = \"FOO\"")?;
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&cfg_file)?.permissions();
perms.set_mode(0);
fs::set_permissions(&cfg_file, perms)?;
let result = load_app_config();
assert!(result.is_err(), "Expected I/O error, got {:?}", result);
Ok(())
}
}