use config::{Config, Environment, File};
use serde::Deserialize;
pub mod app;
pub mod auth;
pub mod azure;
pub mod defaults;
pub mod keys;
pub mod limits;
pub mod setup;
pub mod ui;
pub mod validation;
pub mod wizard;
pub use app::AppConfig;
pub use setup::{find_config_file, get_config_dir, initialize_config_dir, is_config_initialized};
pub use validation::{ConfigLoadResult, ConfigValidationError};
static CONFIG: std::sync::OnceLock<ConfigLoadResult> = std::sync::OnceLock::new();
static RELOADABLE_CONFIG: std::sync::OnceLock<std::sync::RwLock<Option<ConfigLoadResult>>> =
std::sync::OnceLock::new();
#[derive(Debug, Clone)]
struct ProfileInfo {
exists: bool,
last_checked: std::time::SystemTime,
}
struct ProfileCache {
profiles: std::sync::RwLock<std::collections::HashMap<String, ProfileInfo>>,
cached_list: std::sync::RwLock<Option<(Vec<String>, std::time::SystemTime)>>,
cache_ttl: std::time::Duration,
}
impl ProfileCache {
fn new() -> Self {
Self {
profiles: std::sync::RwLock::new(std::collections::HashMap::new()),
cached_list: std::sync::RwLock::new(None),
cache_ttl: std::time::Duration::from_secs(30), }
}
fn is_cached_valid(&self, last_checked: std::time::SystemTime) -> bool {
std::time::SystemTime::now()
.duration_since(last_checked)
.map(|duration| duration < self.cache_ttl)
.unwrap_or(false)
}
fn get_profile_exists(&self, profile_name: &str) -> Option<bool> {
let profiles = self.profiles.read().ok()?;
let profile_info = profiles.get(profile_name)?;
if self.is_cached_valid(profile_info.last_checked) {
Some(profile_info.exists)
} else {
None
}
}
fn cache_profile_exists(&self, profile_name: &str, exists: bool) {
if let Ok(mut profiles) = self.profiles.write() {
profiles.insert(
profile_name.to_string(),
ProfileInfo {
exists,
last_checked: std::time::SystemTime::now(),
},
);
}
}
fn get_cached_profile_list(&self) -> Option<Vec<String>> {
let cached_list = self.cached_list.read().ok()?;
if let Some((ref list, last_checked)) = *cached_list {
if self.is_cached_valid(last_checked) {
return Some(list.clone());
}
}
None
}
fn cache_profile_list(&self, profiles: Vec<String>) {
if let Ok(mut cached_list) = self.cached_list.write() {
*cached_list = Some((profiles, std::time::SystemTime::now()));
}
}
fn invalidate(&self) {
if let Ok(mut profiles) = self.profiles.write() {
profiles.clear();
}
if let Ok(mut cached_list) = self.cached_list.write() {
*cached_list = None;
}
}
}
static PROFILE_CACHE: std::sync::OnceLock<ProfileCache> = std::sync::OnceLock::new();
fn get_profile_cache() -> &'static ProfileCache {
PROFILE_CACHE.get_or_init(ProfileCache::new)
}
static CURRENT_PAGE_SIZE: std::sync::OnceLock<std::sync::Mutex<Option<u32>>> =
std::sync::OnceLock::new();
fn load_config_with_custom_path(custom_config_path: Option<&str>) -> ConfigLoadResult {
if let Ok(config_dir) = setup::get_config_dir() {
let profile_env_path = config_dir.join("profiles").join("default").join(".env");
if profile_env_path.exists() {
dotenv::from_path(profile_env_path).ok();
}
}
let env_source = Environment::default().separator("__");
let config_path = if let Some(custom_path) = custom_config_path {
Some(custom_path.to_string())
} else {
if let Ok(config_dir) = setup::get_config_dir() {
let profile_config_path = config_dir
.join("profiles")
.join("default")
.join("config.toml");
if profile_config_path.exists() {
Some(profile_config_path.to_string_lossy().to_string())
} else {
find_config_file().map(|p| p.to_string_lossy().to_string())
}
} else {
find_config_file().map(|p| p.to_string_lossy().to_string())
}
};
let config = match config_path {
Some(path) => {
use std::path::Path;
if !Path::new(&path).exists() && custom_config_path.is_some() {
log::warn!("Custom config file not found: {path}");
match create_config_file_with_defaults(&path) {
Ok(()) => {
log::info!("Created default config file at: {path}");
}
Err(e) => {
return ConfigLoadResult::LoadError(format!(
"Custom config file '{path}' does not exist and failed to create it with defaults: {e}\n\
\nSuggestions:\n\
1. Create the config file manually\n\
2. Use 'quetty --setup' for interactive configuration\n\
3. Run without --config to use default locations"
));
}
}
}
log::info!("Loading configuration from: {path}");
let file_source = File::with_name(&path);
let keys_source = if let Ok(config_dir) = setup::get_config_dir() {
let profile_keys_path = config_dir
.join("profiles")
.join("default")
.join("keys.toml");
if profile_keys_path.exists() {
log::info!("Loading keys from: {}", profile_keys_path.display());
Some(File::with_name(&profile_keys_path.to_string_lossy()))
} else {
log::debug!(
"No keys.toml found at: {}, using embedded defaults",
profile_keys_path.display()
);
None
}
} else {
None
};
let mut config_builder = Config::builder().add_source(file_source);
if let Some(keys_file) = keys_source {
config_builder = config_builder.add_source(keys_file);
}
config_builder = config_builder.add_source(env_source);
match config_builder.build() {
Ok(config) => config,
Err(e) => {
return ConfigLoadResult::LoadError(format!(
"Configuration loading failed from {path}: {e}. Please check your config file and environment variables."
));
}
}
}
None => {
log::warn!("No configuration file found. Attempting to initialize with defaults...");
match initialize_config_dir() {
Ok(config_dir) => {
log::info!("Initialized config directory: {}", config_dir.display());
if let Some(new_config_path) = find_config_file() {
let file_source = File::with_name(&new_config_path.to_string_lossy());
let keys_source = if let Ok(config_dir) = setup::get_config_dir() {
let profile_keys_path = config_dir
.join("profiles")
.join("default")
.join("keys.toml");
if profile_keys_path.exists() {
log::info!("Loading keys from: {}", profile_keys_path.display());
Some(File::with_name(&profile_keys_path.to_string_lossy()))
} else {
log::debug!(
"No keys.toml found at: {}, using embedded defaults",
profile_keys_path.display()
);
None
}
} else {
None
};
let mut config_builder = Config::builder().add_source(file_source);
if let Some(keys_file) = keys_source {
config_builder = config_builder.add_source(keys_file);
}
config_builder = config_builder.add_source(env_source);
match config_builder.build() {
Ok(config) => config,
Err(e) => {
return ConfigLoadResult::LoadError(format!(
"Failed to load newly created config: {e}"
));
}
}
} else {
return ConfigLoadResult::LoadError(
"Failed to find config file after initialization".to_string(),
);
}
}
Err(e) => {
return ConfigLoadResult::LoadError(format!(
"No configuration file found and failed to initialize defaults: {e}. \
Please create a config.toml file or run with --setup flag."
));
}
}
}
};
match config.try_deserialize::<AppConfig>() {
Ok(app_config) => ConfigLoadResult::Success(Box::new(app_config)),
Err(e) => ConfigLoadResult::DeserializeError(format!("Failed to deserialize config: {e}")),
}
}
pub fn get_config() -> &'static ConfigLoadResult {
if let Some(reloadable_lock) = RELOADABLE_CONFIG.get() {
if let Ok(guard) = reloadable_lock.read() {
if let Some(ref reloaded_config) = *guard {
log::debug!("Using reloaded configuration instead of cached config");
return unsafe {
std::mem::transmute::<&ConfigLoadResult, &ConfigLoadResult>(reloaded_config)
};
}
}
}
get_config_for_profile("default")
}
pub fn get_config_or_panic() -> &'static AppConfig {
match get_config() {
ConfigLoadResult::Success(config) => config,
ConfigLoadResult::LoadError(e) => {
panic!("Failed to load config: {e}");
}
ConfigLoadResult::DeserializeError(e) => {
panic!("Failed to deserialize config: {e}");
}
}
}
pub fn get_current_page_size() -> u32 {
let current_page_size = CURRENT_PAGE_SIZE.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(guard) = current_page_size.lock() {
if let Some(size) = *guard {
return size;
}
}
get_config_or_panic().max_messages()
}
pub fn set_current_page_size(page_size: u32) {
let current_page_size = CURRENT_PAGE_SIZE.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(mut guard) = current_page_size.lock() {
*guard = Some(page_size);
}
}
pub fn reload_config() -> Result<(), String> {
log::info!("Reloading configuration from files and environment variables");
let fresh_config = load_config_with_custom_path(None);
let reloadable_lock = RELOADABLE_CONFIG.get_or_init(|| std::sync::RwLock::new(None));
match reloadable_lock.write() {
Ok(mut guard) => {
*guard = Some(fresh_config.clone());
match fresh_config {
ConfigLoadResult::Success(_) => {
log::info!("Configuration reloaded successfully");
Ok(())
}
ConfigLoadResult::LoadError(msg) => {
log::error!("Configuration reload failed: {msg}");
Err(msg)
}
ConfigLoadResult::DeserializeError(msg) => {
log::error!("Configuration reload failed during deserialization: {msg}");
Err(msg)
}
}
}
Err(e) => {
let error_msg = format!("Failed to acquire write lock for configuration reload: {e}");
log::error!("{error_msg}");
Err(error_msg)
}
}
}
pub fn init_config_from_path(config_path: &str) -> &'static ConfigLoadResult {
CONFIG.get_or_init(|| load_config_with_custom_path(Some(config_path)))
}
pub fn get_config_for_profile(profile_name: &str) -> &'static ConfigLoadResult {
CONFIG.get_or_init(|| load_config_for_profile(profile_name))
}
pub fn validate_profile_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Profile name cannot be empty".to_string());
}
if name.len() > 64 {
return Err("Profile name cannot be longer than 64 characters".to_string());
}
if name.contains("..") || name.contains('/') || name.contains('\\') {
return Err(
"Profile name cannot contain path separators or traversal sequences".to_string(),
);
}
if name == "." || name == ".." {
return Err("Profile name cannot be '.' or '..'".to_string());
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(
"Profile name can only contain letters, numbers, dashes, and underscores".to_string(),
);
}
Ok(())
}
fn safe_profile_path(profile_name: &str) -> Result<std::path::PathBuf, String> {
use crate::config::setup::get_config_dir;
validate_profile_name(profile_name)?;
let config_dir =
get_config_dir().map_err(|e| format!("Failed to determine config directory: {e}"))?;
Ok(config_dir.join("profiles").join(profile_name))
}
pub fn profile_exists(profile_name: &str) -> bool {
let cache = get_profile_cache();
if let Some(cached_result) = cache.get_profile_exists(profile_name) {
return cached_result;
}
let exists = match safe_profile_path(profile_name) {
Ok(profile_dir) => profile_dir.exists() && profile_dir.join(".env").exists(),
Err(_) => false, };
cache.cache_profile_exists(profile_name, exists);
exists
}
pub fn list_available_profiles() -> Vec<String> {
use crate::config::setup::get_config_dir;
let cache = get_profile_cache();
if let Some(cached_profiles) = cache.get_cached_profile_list() {
return cached_profiles;
}
let mut profiles = Vec::new();
if let Ok(config_dir) = get_config_dir() {
let profiles_dir = config_dir.join("profiles");
if let Ok(entries) = std::fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if metadata.is_dir() {
if let Some(name) = entry.file_name().to_str() {
if validate_profile_name(name).is_ok() {
let env_path = entry.path().join(".env");
if env_path.exists() {
profiles.push(name.to_string());
}
}
}
}
}
}
}
}
profiles.sort();
cache.cache_profile_list(profiles.clone());
profiles
}
pub fn invalidate_profile_cache() {
get_profile_cache().invalidate();
}
fn load_config_for_profile(profile_name: &str) -> ConfigLoadResult {
let profile_dir = match safe_profile_path(profile_name) {
Ok(path) => path,
Err(validation_error) => {
return ConfigLoadResult::LoadError(format!(
"Invalid profile name '{profile_name}': {validation_error}"
));
}
};
if !profile_exists(profile_name) {
log::warn!("Profile '{profile_name}' does not exist, falling back to embedded defaults");
let available_profiles = list_available_profiles();
if !available_profiles.is_empty() {
log::info!("Available profiles: {}", available_profiles.join(", "));
}
use crate::config::defaults::{DEFAULT_CONFIG, DEFAULT_KEYS};
let env_source = Environment::default().separator("__");
let config_builder = Config::builder()
.add_source(config::File::from_str(
DEFAULT_CONFIG,
config::FileFormat::Toml,
))
.add_source(config::File::from_str(
DEFAULT_KEYS,
config::FileFormat::Toml,
))
.add_source(env_source);
let config = match config_builder.build() {
Ok(config) => config,
Err(e) => {
return ConfigLoadResult::LoadError(format!(
"Failed to load embedded defaults: {e}"
));
}
};
return match config.try_deserialize::<AppConfig>() {
Ok(app_config) => ConfigLoadResult::Success(Box::new(app_config)),
Err(e) => ConfigLoadResult::DeserializeError(format!(
"Failed to deserialize embedded config: {e}"
)),
};
}
if let Ok(env_path) = profile_dir.join(".env").canonicalize() {
dotenv::from_path(env_path).ok();
}
let profile_config_path = profile_dir.join("config.toml");
let config_path = if profile_config_path.exists() {
Some(profile_config_path.to_string_lossy().to_string())
} else {
None
};
load_config_with_custom_path(config_path.as_deref())
}
fn create_config_file_with_defaults(config_path: &str) -> Result<(), Box<dyn std::error::Error>> {
use crate::config::defaults::get_complete_default_config;
use std::path::Path;
let path = Path::new(config_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, get_complete_default_config())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(path, permissions)?;
}
Ok(())
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct LoggingConfig {
level: Option<String>,
file: Option<String>,
max_file_size_mb: Option<u64>,
max_backup_files: Option<u32>,
cleanup_on_startup: Option<bool>,
}
impl LoggingConfig {
pub fn level(&self) -> &str {
self.level.as_deref().unwrap_or("info")
}
pub fn file(&self) -> Option<&str> {
self.file.as_deref()
}
pub fn max_file_size_mb(&self) -> u64 {
self.max_file_size_mb.unwrap_or(10)
}
pub fn max_backup_files(&self) -> u32 {
self.max_backup_files.unwrap_or(5)
}
pub fn cleanup_on_startup(&self) -> bool {
self.cleanup_on_startup.unwrap_or(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_profile_name_valid_names() {
assert!(validate_profile_name("default").is_ok());
assert!(validate_profile_name("dev").is_ok());
assert!(validate_profile_name("production").is_ok());
assert!(validate_profile_name("test-env").is_ok());
assert!(validate_profile_name("test_env").is_ok());
assert!(validate_profile_name("dev123").is_ok());
assert!(validate_profile_name("a").is_ok());
assert!(validate_profile_name("A-B_C1").is_ok());
}
#[test]
fn test_validate_profile_name_invalid_names() {
assert!(validate_profile_name("").is_err());
let long_name = "a".repeat(65);
assert!(validate_profile_name(&long_name).is_err());
assert!(validate_profile_name("../etc/passwd").is_err());
assert!(validate_profile_name("../../root").is_err());
assert!(validate_profile_name("..\\windows").is_err());
assert!(validate_profile_name("test/../etc").is_err());
assert!(validate_profile_name("test\\..\\etc").is_err());
assert!(validate_profile_name("test/profile").is_err());
assert!(validate_profile_name("test\\profile").is_err());
assert!(validate_profile_name("/etc/passwd").is_err());
assert!(validate_profile_name("C:\\Windows").is_err());
assert!(validate_profile_name(".").is_err());
assert!(validate_profile_name("..").is_err());
assert!(validate_profile_name("test profile").is_err()); assert!(validate_profile_name("test@profile").is_err()); assert!(validate_profile_name("test#profile").is_err()); assert!(validate_profile_name("test$profile").is_err()); assert!(validate_profile_name("test%profile").is_err()); assert!(validate_profile_name("test^profile").is_err()); assert!(validate_profile_name("test&profile").is_err()); assert!(validate_profile_name("test*profile").is_err()); assert!(validate_profile_name("test(profile").is_err()); assert!(validate_profile_name("test)profile").is_err()); assert!(validate_profile_name("test+profile").is_err()); assert!(validate_profile_name("test=profile").is_err()); assert!(validate_profile_name("test[profile").is_err()); assert!(validate_profile_name("test]profile").is_err()); assert!(validate_profile_name("test{profile").is_err()); assert!(validate_profile_name("test}profile").is_err()); assert!(validate_profile_name("test|profile").is_err()); assert!(validate_profile_name("test:profile").is_err()); assert!(validate_profile_name("test;profile").is_err()); assert!(validate_profile_name("test\"profile").is_err()); assert!(validate_profile_name("test'profile").is_err()); assert!(validate_profile_name("test<profile").is_err()); assert!(validate_profile_name("test>profile").is_err()); assert!(validate_profile_name("test,profile").is_err()); assert!(validate_profile_name("test?profile").is_err()); assert!(validate_profile_name("test`profile").is_err()); assert!(validate_profile_name("test~profile").is_err()); assert!(validate_profile_name("test!profile").is_err()); }
#[test]
fn test_safe_profile_path_security() {
let result = safe_profile_path("valid-profile");
assert!(result.is_ok());
if let Ok(path) = result {
let path_str = path.to_string_lossy();
assert!(path_str.contains("profiles"));
assert!(path_str.contains("valid-profile"));
assert!(!path_str.contains(".."));
}
assert!(safe_profile_path("../etc/passwd").is_err());
assert!(safe_profile_path("../../root").is_err());
assert!(safe_profile_path("..\\windows").is_err());
assert!(safe_profile_path("").is_err());
assert!(safe_profile_path("test/profile").is_err());
assert!(safe_profile_path("test\\profile").is_err());
}
#[test]
fn test_profile_cache_functionality() {
let cache = ProfileCache::new();
assert!(cache.get_profile_exists("test").is_none());
cache.cache_profile_exists("test", true);
assert_eq!(cache.get_profile_exists("test"), Some(true));
cache.cache_profile_exists("test2", false);
assert_eq!(cache.get_profile_exists("test2"), Some(false));
assert!(cache.get_cached_profile_list().is_none());
let test_profiles = vec!["profile1".to_string(), "profile2".to_string()];
cache.cache_profile_list(test_profiles.clone());
assert_eq!(cache.get_cached_profile_list(), Some(test_profiles));
cache.invalidate();
assert!(cache.get_profile_exists("test").is_none());
assert!(cache.get_cached_profile_list().is_none());
}
#[test]
fn test_profile_validation_error_messages() {
let result = validate_profile_name("");
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
let result = validate_profile_name("../etc/passwd");
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("traversal"));
let result = validate_profile_name("test profile");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("letters, numbers, dashes, and underscores")
);
let long_name = "a".repeat(65);
let result = validate_profile_name(&long_name);
assert!(result.is_err());
assert!(result.unwrap_err().contains("longer than 64 characters"));
}
}