use crate::error::{ProxyError, Result};
#[cfg(not(target_os = "macos"))]
use directories::ProjectDirs;
use std::path::{Path, PathBuf};
const APP_NAME: &str = "modelmux";
#[cfg(not(target_os = "macos"))]
const ORGANIZATION: &str = "com";
#[cfg(not(target_os = "macos"))]
const ORG_NAME: &str = "SkyCorp";
pub fn user_config_dir() -> Result<PathBuf> {
let config_dir = resolve_user_config_dir()?;
ensure_directory_exists(&config_dir)?;
Ok(config_dir)
}
#[allow(dead_code)]
pub fn user_data_dir() -> Result<PathBuf> {
let data_dir = resolve_user_data_dir()?;
ensure_directory_exists(&data_dir)?;
Ok(data_dir)
}
#[allow(dead_code)]
pub fn user_cache_dir() -> Result<PathBuf> {
let cache_dir = resolve_user_cache_dir()?;
ensure_directory_exists(&cache_dir)?;
Ok(cache_dir)
}
pub fn user_log_dir() -> Result<PathBuf> {
let dir = resolve_user_log_dir()?;
ensure_directory_exists(&dir)?;
Ok(dir)
}
pub fn system_config_dir() -> Result<PathBuf> {
#[cfg(unix)]
{
Ok(PathBuf::from("/etc").join(APP_NAME))
}
#[cfg(windows)]
{
std::env::var("PROGRAMDATA").map(|path| PathBuf::from(path).join(APP_NAME)).map_err(|_| {
ProxyError::Config("PROGRAMDATA environment variable not found".to_string())
})
}
}
pub fn user_config_file() -> Result<PathBuf> {
Ok(user_config_dir()?.join("config.toml"))
}
pub fn system_config_file() -> Result<PathBuf> {
Ok(system_config_dir()?.join("config.toml"))
}
pub fn default_service_account_file() -> Result<PathBuf> {
Ok(user_config_dir()?.join("service-account.json"))
}
#[cfg(target_os = "macos")]
pub fn legacy_macos_user_config_files() -> Vec<PathBuf> {
legacy_macos_user_config_dirs()
.into_iter()
.map(|dir| dir.join("config.toml"))
.collect()
}
#[cfg(target_os = "macos")]
pub fn legacy_macos_user_config_dirs() -> Vec<PathBuf> {
let Ok(home) = home_dir() else {
return Vec::new();
};
let app_support = home.join("Library").join("Application Support");
vec![app_support.join("com.SkyCorp.modelmux"), app_support.join(APP_NAME)]
}
pub fn expand_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path_str = path.as_ref().to_string_lossy();
if let Some(rest) = path_str.strip_prefix("~/") {
if let Some(dirs) = directories::UserDirs::new() {
let expanded = dirs.home_dir().join(rest);
return Ok(expanded);
} else {
return Err(ProxyError::Config(
"Unable to determine user home directory for tilde expansion".to_string(),
));
}
}
if path_str.contains('$') {
let expanded = shellexpand::full(&path_str).map_err(|e| {
ProxyError::Config(format!(
"Failed to expand environment variables in path '{}': {}",
path_str, e
))
})?;
return Ok(PathBuf::from(expanded.as_ref()));
}
Ok(path.as_ref().to_path_buf())
}
pub fn validate_config_file<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
if !path.exists() {
return Err(ProxyError::Config(format!(
"Configuration file '{}' does not exist",
path.display()
)));
}
if !path.is_file() {
return Err(ProxyError::Config(format!(
"Configuration path '{}' exists but is not a regular file",
path.display()
)));
}
std::fs::File::open(path).map_err(|e| {
ProxyError::Config(format!(
"Configuration file '{}' exists but cannot be read: {}\n\
\n\
Please check file permissions. The file should be readable by the current user.\n\
You can fix this with: chmod 644 '{}'",
path.display(),
e,
path.display()
))
})?;
Ok(())
}
pub fn config_file_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(user_config) = user_config_file() {
paths.push(user_config);
}
#[cfg(target_os = "macos")]
{
for legacy in legacy_macos_user_config_files() {
paths.push(legacy);
}
}
if let Ok(system_config) = system_config_file() {
paths.push(system_config);
}
paths
}
fn resolve_user_config_dir() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
{
Ok(home_dir()?.join(".config").join(APP_NAME))
}
#[cfg(not(target_os = "macos"))]
{
Ok(get_project_dirs()?.config_dir().to_path_buf())
}
}
fn resolve_user_data_dir() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
{
Ok(home_dir()?.join(".local").join("share").join(APP_NAME))
}
#[cfg(not(target_os = "macos"))]
{
Ok(get_project_dirs()?.data_dir().to_path_buf())
}
}
fn resolve_user_log_dir() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
{
Ok(home_dir()?.join("Library").join("Logs").join(APP_NAME))
}
#[cfg(target_os = "linux")]
{
if let Some(state) = get_project_dirs()?.state_dir() {
Ok(state.to_path_buf())
} else {
Ok(home_dir()?.join(".local").join("state").join(APP_NAME))
}
}
#[cfg(target_os = "windows")]
{
Ok(get_project_dirs()?.data_local_dir().join("Logs"))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
Ok(home_dir()?.join(".local").join("state").join(APP_NAME))
}
}
fn resolve_user_cache_dir() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
{
Ok(home_dir()?.join(".cache").join(APP_NAME))
}
#[cfg(not(target_os = "macos"))]
{
Ok(get_project_dirs()?.cache_dir().to_path_buf())
}
}
fn home_dir() -> Result<PathBuf> {
directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()).ok_or_else(|| {
ProxyError::Config(
"Unable to determine user home directory. This may indicate:\n\
1. No valid home directory found\n\
2. Platform-specific directory resolution failed\n\
3. Insufficient permissions to access user directories\n\
\n\
Please ensure your user account has a valid home directory."
.to_string(),
)
})
}
#[cfg(not(target_os = "macos"))]
fn get_project_dirs() -> Result<ProjectDirs> {
ProjectDirs::from(ORGANIZATION, ORG_NAME, APP_NAME).ok_or_else(|| {
ProxyError::Config(
"Unable to determine user directories. This may indicate:\n\
1. No valid home directory found\n\
2. Platform-specific directory resolution failed\n\
3. Insufficient permissions to access user directories\n\
\n\
Please ensure your user account has a valid home directory."
.to_string(),
)
})
}
fn ensure_directory_exists<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
if path.exists() {
if !path.is_dir() {
return Err(ProxyError::Config(format!(
"Path '{}' exists but is not a directory",
path.display()
)));
}
return Ok(());
}
std::fs::create_dir_all(path).map_err(|e| {
ProxyError::Config(format!(
"Failed to create configuration directory '{}': {}\n\
\n\
Please ensure:\n\
1. You have write permissions to the parent directory\n\
2. There's sufficient disk space\n\
3. No conflicting files exist in the path",
path.display(),
e
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_user_config_dir_creation() {
let config_dir = user_config_dir().expect("Should get user config directory");
assert!(config_dir.exists(), "Config directory should be created");
assert!(config_dir.is_dir(), "Config path should be a directory");
}
#[test]
fn test_user_config_file_path() {
let config_file = user_config_file().expect("Should get config file path");
assert!(config_file.file_name().unwrap() == "config.toml");
assert!(config_file.parent().unwrap().exists(), "Parent directory should exist");
}
#[test]
fn test_tilde_expansion() {
let expanded = expand_path("~/test/path").expect("Should expand tilde");
assert!(!expanded.to_string_lossy().contains('~'), "Tilde should be expanded");
let absolute = expand_path("/absolute/path").expect("Should handle absolute path");
assert_eq!(absolute, PathBuf::from("/absolute/path"));
}
#[test]
fn test_validate_config_file() {
let result = validate_config_file("/non/existent/file.toml");
assert!(result.is_err());
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.toml");
fs::write(&temp_file, "test content").unwrap();
let result = validate_config_file(&temp_file);
assert!(result.is_ok());
}
#[test]
fn test_config_file_paths_order() {
let paths = config_file_paths();
assert!(!paths.is_empty(), "Should return at least one config path");
if paths.len() > 1 {
let user_path = &paths[0];
let system_path = paths.last().expect("paths is non-empty");
assert!(
user_path.to_string_lossy().contains("config"),
"First path should be user config"
);
#[cfg(unix)]
assert!(
system_path.starts_with("/etc"),
"System path should live under /etc on Unix-like systems, got: {}",
system_path.display()
);
}
}
#[test]
#[cfg(target_os = "macos")]
fn test_user_config_dir_uses_xdg_on_macos() {
let config_dir = user_config_dir().expect("Should get user config directory");
let config_str = config_dir.to_string_lossy();
assert!(
config_str.contains("/.config/modelmux"),
"macOS user config should be ~/.config/modelmux, got: {}",
config_str
);
assert!(
!config_str.contains("Library/Application Support"),
"macOS user config must no longer live under Library/Application Support, got: {}",
config_str
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_legacy_macos_paths_are_listed() {
let legacy = legacy_macos_user_config_files();
assert!(
legacy.iter().any(|p| p.to_string_lossy().contains("com.SkyCorp.modelmux")),
"Legacy macOS lookup must include com.SkyCorp.modelmux/config.toml"
);
}
#[test]
fn test_default_service_account_file() {
let sa_file = default_service_account_file().expect("Should get service account path");
assert!(sa_file.file_name().unwrap() == "service-account.json");
assert!(sa_file.parent().unwrap().exists(), "Parent directory should exist");
}
}