use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tracing::{debug, warn};
use crate::cli::CliArgs;
use super::{KeyBindings, Theme};
const EXCLUDE_DIR_PATTERN: &[&str] = &[
"node_modules",
"target",
"venv",
"build",
"site",
"out",
"dist",
"bin",
"obj",
"Debug",
"Release",
"cache",
"tmp",
"temp",
"log",
"logs",
"*log",
"*logs",
"Library",
"Applications",
"AppData",
];
#[derive(Debug, Clone, Default)]
pub struct AppConfig {
pub main: MainConfig,
pub ui: UIConfig,
pub internal: InternalConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MainConfig {
pub scan_dirs: Vec<String>,
pub max_depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UIConfig {
pub theme: Theme,
#[serde(default)]
pub keybindings: KeyBindings,
}
#[derive(Debug, Clone)]
pub struct InternalConfig {
pub exclude_dirs: Vec<String>,
pub refresh_interval: u64,
pub cwd_file: Option<String>,
}
impl Default for MainConfig {
fn default() -> Self {
Self {
scan_dirs: vec![dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| {
debug!("Could not determine home directory, using current directory as default scan directory");
".".to_string()
})],
max_depth: 5,
}
}
}
impl Default for InternalConfig {
fn default() -> Self {
Self {
exclude_dirs: EXCLUDE_DIR_PATTERN.iter().map(|s| s.to_string()).collect(),
refresh_interval: 100,
cwd_file: None,
}
}
}
#[derive(Serialize, Deserialize, Default)]
#[serde(default)]
struct AppConfigUserFields {
main: MainConfig,
ui: UIConfig,
}
impl AppConfig {
pub fn from_layers(cli_args: &CliArgs) -> Self {
let mut config = Self::default();
if let Some(file_config) = Self::load_from_file(cli_args.config.as_deref()) {
config.merge_file_config(file_config);
}
config.apply_cli_overrides(cli_args);
debug!("Final scan directories: {:?}", config.main.scan_dirs);
config
}
fn get_search_paths(cli_config_path: Option<&str>) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_path) = cli_config_path {
let expanded_path = PathBuf::from(expand_tilde_in_path(config_path));
debug!("Using config path from CLI argument: {:?}", expanded_path);
paths.push(expanded_path);
}
if let Ok(config_path) = std::env::var("REPONEST_CONFIG") {
let expanded_path = PathBuf::from(expand_tilde_in_path(&config_path));
debug!(
"Using config path from REPONEST_CONFIG: {:?}",
expanded_path
);
paths.push(expanded_path);
}
if let Some(dir) = dirs::config_dir() {
paths.push(dir.join("reponest").join("config.toml"));
}
if let Some(dir) = dirs::home_dir() {
let fallback = dir.join(".config").join("reponest").join("config.toml");
if !paths.contains(&fallback) {
paths.push(fallback);
}
}
paths
}
fn load_from_file(cli_config_path: Option<&str>) -> Option<AppConfigUserFields> {
let config_paths = Self::get_search_paths(cli_config_path);
debug!("Searching for config file in paths: {:?}", config_paths);
for config_path in &config_paths {
if config_path.exists() {
debug!("Loading config from: {:?}", config_path);
match fs::read_to_string(config_path) {
Ok(content) => match toml::from_str::<AppConfigUserFields>(&content) {
Ok(config) => {
debug!("Successfully loaded config from file");
return Some(config);
}
Err(e) => {
warn!(
"Failed to parse config file at {:?}: {}. Using defaults",
config_path, e
);
return None;
}
},
Err(e) => {
warn!(
"Failed to read config file at {:?}: {}. Using defaults",
config_path, e
);
return None;
}
}
}
}
debug!("No config file found in search paths: {:?}", config_paths);
None
}
fn merge_file_config(&mut self, mut file_config: AppConfigUserFields) {
file_config.main.scan_dirs = file_config
.main
.scan_dirs
.iter()
.map(|p| expand_tilde_in_path(p))
.collect();
self.main = file_config.main;
self.ui = file_config.ui;
}
fn apply_cli_overrides(&mut self, args: &CliArgs) {
if let Some(ref path) = args.path {
debug!("CLI override: scan_dirs = [{}]", path);
self.main.scan_dirs = vec![path.clone()];
}
if let Some(depth) = args.max_depth {
debug!("CLI override: max_depth = {}", depth);
self.main.max_depth = depth;
}
if let Some(ref theme_str) = args.theme {
match theme_str.parse::<Theme>() {
Ok(theme) => {
debug!("CLI override: theme = {}", theme);
self.ui.theme = theme;
}
Err(e) => {
warn!("Invalid theme '{}': {}. Using default theme.", theme_str, e);
}
}
}
if let Some(ref cwd_file) = args.cwd_file {
debug!("CLI override: cwd_file = {}", cwd_file);
self.internal.cwd_file = Some(cwd_file.clone());
}
}
pub fn print(&self) {
let user_fields = AppConfigUserFields {
main: self.main.clone(),
ui: self.ui.clone(),
};
match serde_json::to_string_pretty(&user_fields) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Failed to serialize configuration: {}", e),
}
}
}
fn expand_tilde_in_path(path: &str) -> String {
if path.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
return path.replacen("~", &home.to_string_lossy(), 1);
}
} else if path == "~"
&& let Some(home) = dirs::home_dir()
{
return home.to_string_lossy().to_string();
}
path.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_expand_tilde_in_path() {
let home = dirs::home_dir().unwrap();
let home_str = home.to_string_lossy();
let result = expand_tilde_in_path("~/test/path");
assert!(result.starts_with(&*home_str));
assert!(result.ends_with("test/path"));
let result = expand_tilde_in_path("~");
assert_eq!(result, home_str);
let result = expand_tilde_in_path("/absolute/path");
assert_eq!(result, "/absolute/path");
}
#[test]
fn test_cli_config_priority() {
#[cfg(target_os = "windows")]
let (custom_path, env_path, cli_path) = (
"C:\\custom\\config.toml",
"C:\\env\\config.toml",
"C:\\cli\\config.toml",
);
#[cfg(not(target_os = "windows"))]
let (custom_path, env_path, cli_path) = (
"/custom/config.toml",
"/env/config.toml",
"/cli/config.toml",
);
let paths = AppConfig::get_search_paths(Some(custom_path));
assert_eq!(paths[0], PathBuf::from(custom_path));
#[cfg(not(target_os = "windows"))]
{
let paths = AppConfig::get_search_paths(Some("~/my-config.toml"));
assert!(!paths[0].to_string_lossy().contains('~'));
assert!(paths[0].to_string_lossy().contains("my-config.toml"));
}
let original = env::var("REPONEST_CONFIG").ok();
unsafe {
env::set_var("REPONEST_CONFIG", env_path);
}
let paths = AppConfig::get_search_paths(Some(cli_path));
assert_eq!(paths[0], PathBuf::from(cli_path));
assert!(
paths.len() >= 2,
"Expected at least 2 paths (CLI + env/system), got {} paths: {:?}",
paths.len(),
paths
);
if paths.len() >= 2 {
let env_pathbuf = PathBuf::from(env_path);
assert!(
paths[1] == env_pathbuf || paths.contains(&env_pathbuf),
"Expected env path {:?} at index 1 or in list, got paths: {:?}",
env_pathbuf,
paths
);
}
unsafe {
match original {
Some(val) => env::set_var("REPONEST_CONFIG", val),
None => env::remove_var("REPONEST_CONFIG"),
}
}
}
#[test]
fn test_config_env_var() {
let original = env::var("REPONEST_CONFIG").ok();
#[cfg(target_os = "windows")]
let test_path = "C:\\tmp\\test-config.toml";
#[cfg(not(target_os = "windows"))]
let test_path = "/tmp/test-config.toml";
unsafe {
env::set_var("REPONEST_CONFIG", test_path);
}
let paths = AppConfig::get_search_paths(None);
assert_eq!(paths[0], PathBuf::from(test_path));
#[cfg(not(target_os = "windows"))]
{
unsafe {
env::set_var("REPONEST_CONFIG", "~/my-config.toml");
}
let paths = AppConfig::get_search_paths(None);
assert!(!paths[0].to_string_lossy().contains('~'));
assert!(paths[0].to_string_lossy().contains("my-config.toml"));
}
unsafe {
match original {
Some(val) => env::set_var("REPONEST_CONFIG", val),
None => env::remove_var("REPONEST_CONFIG"),
}
}
}
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert!(!config.main.scan_dirs.is_empty());
assert_eq!(config.main.max_depth, 5);
assert!(!config.internal.exclude_dirs.is_empty());
}
}