#![allow(dead_code)]
use anyhow::{anyhow, Context, Result};
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
#[derive(Default)]
pub struct ConfigFile {
pub defaults: Option<String>,
pub aliases: HashMap<String, String>,
}
impl ConfigFile {
pub fn find_project_config() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let config_path = current.join(".kelora.ini");
if config_path.exists() {
return Some(config_path);
}
if !current.pop() {
break;
}
}
None
}
pub fn get_user_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if cfg!(windows) {
if let Ok(appdata) = env::var("APPDATA") {
paths.push(PathBuf::from(appdata).join("kelora.ini"));
}
} else {
let config_dir = env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
env::var("HOME")
.map(|h| PathBuf::from(h).join(".config"))
.unwrap_or_else(|_| PathBuf::from(".config"))
});
paths.push(config_dir.join("kelora.ini"));
}
paths
}
pub fn get_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(project_config) = Self::find_project_config() {
paths.push(project_config);
}
paths.extend(Self::get_user_config_paths());
paths
}
pub fn find_config_path() -> Option<PathBuf> {
Self::get_config_paths().into_iter().find(|p| p.exists())
}
pub fn load() -> Result<Self> {
let mut config = Self::default();
for path in Self::get_user_config_paths() {
if path.exists() {
let user_config = Self::load_from_path(&path)?;
config = Self::merge_configs(config, user_config);
break; }
}
if let Some(project_path) = Self::find_project_config() {
let project_config = Self::load_from_path(&project_path)?;
config = Self::merge_configs(config, project_config);
}
Ok(config)
}
pub fn load_with_custom_path(custom_path: Option<&str>) -> Result<Self> {
if let Some(path) = custom_path {
let path_buf = std::path::PathBuf::from(path);
Self::load_from_path(&path_buf)
} else {
Self::load()
}
}
pub fn load_from_path(path: &PathBuf) -> Result<Self> {
use std::fs;
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config = Self::parse_ini_content(&content)?;
Ok(config)
}
fn parse_ini_content(content: &str) -> Result<Self> {
let mut defaults = None;
let mut aliases = HashMap::new();
let mut current_section = String::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
current_section = line[1..line.len() - 1].to_string();
continue;
}
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim();
let value = line[eq_pos + 1..].trim();
if current_section.is_empty() {
if key == "defaults" {
defaults = Some(value.to_string());
}
} else {
match current_section.as_str() {
"aliases" => {
aliases.insert(key.to_string(), value.to_string());
}
_ => {
}
}
}
}
}
Ok(Self { defaults, aliases })
}
fn merge_configs(base: Self, overlay: Self) -> Self {
Self {
defaults: overlay.defaults.or(base.defaults),
aliases: {
let mut merged = base.aliases;
merged.extend(overlay.aliases);
merged
},
}
}
pub fn show_config() {
println!(
"Configuration precedence: CLI > project .kelora.ini > user kelora.ini > defaults\n"
);
let project_config_path = Self::find_project_config();
let user_config_paths = Self::get_user_config_paths();
let user_config_path = user_config_paths.iter().find(|p| p.exists());
match Self::load() {
Ok(merged_config) => {
let mut loaded_from = Vec::new();
if let Some(project_path) = &project_config_path {
loaded_from.push(format!("Project: {}", project_path.display()));
}
if let Some(user_path) = user_config_path {
loaded_from.push(format!("User: {}", user_path.display()));
}
if loaded_from.is_empty() {
println!("No configuration files found. Using defaults.");
} else {
println!("Configuration loaded from:");
for source in loaded_from {
println!(" {}", source);
}
}
if let Some(defaults) = &merged_config.defaults {
println!("\nActive defaults:");
println!(" defaults = {}", defaults);
}
if !merged_config.aliases.is_empty() {
println!("\nActive aliases:");
let mut sorted_aliases: Vec<_> = merged_config.aliases.iter().collect();
sorted_aliases.sort_by_key(|(k, _)| k.as_str());
for (key, value) in sorted_aliases {
println!(" {} = {}", key, value);
}
}
}
Err(e) => {
eprintln!("Error loading configuration: {}", e);
}
}
println!("\nConfiguration search locations (in precedence order):");
if let Some(ref project_path) = project_config_path {
println!(
" 1. Project: {} {}",
project_path.display(),
if project_path.exists() {
"(found)"
} else {
"(not found)"
}
);
} else {
println!(" 1. Project: .kelora.ini (searched up directory tree, not found)");
}
for (i, path) in user_config_paths.iter().enumerate() {
let status = if path.exists() {
"(found)"
} else {
"(not found)"
};
println!(" {}. User: {} {}", i + 2, path.display(), status);
}
if project_config_path.is_none() && user_config_path.is_none() {
println!("\nExample configuration file (.kelora.ini):");
println!();
println!("# Set default arguments applied to every kelora command");
println!("defaults = --format auto --stats --input-tz UTC");
println!();
println!("[aliases]");
println!("errors = -l error --since 1h --stats");
println!("json-errors = --format json -l error --output-format json");
println!("slow-requests = --filter 'e.response_time.to_int() > 1000' --keys timestamp,method,path,response_time");
}
}
pub fn resolve_alias(
&self,
name: &str,
seen: &mut std::collections::HashSet<String>,
depth: usize,
) -> Result<Vec<String>> {
const MAX_DEPTH: usize = 10;
if depth > MAX_DEPTH {
return Err(anyhow!("Alias chain too deep: {} levels", depth));
}
if seen.contains(name) {
return Err(anyhow!("Circular dependency detected in alias: {}", name));
}
let alias_value = self
.aliases
.get(name)
.ok_or_else(|| anyhow!("Unknown alias: {}", name))?;
seen.insert(name.to_string());
let args = shell_words::split(alias_value)
.with_context(|| format!("Invalid alias '{}': failed to parse arguments", name))?;
let mut result = Vec::new();
let mut i = 0;
while i < args.len() {
if (args[i] == "-a" || args[i] == "--alias") && i + 1 < args.len() {
let ref_name = &args[i + 1];
let mut new_seen = seen.clone();
let resolved = self.resolve_alias(ref_name, &mut new_seen, depth + 1)?;
result.extend(resolved);
i += 2;
} else {
result.push(args[i].clone());
i += 1;
}
}
seen.remove(name);
Ok(result)
}
pub fn process_args(&self, args: Vec<String>) -> Result<Vec<String>> {
let mut result = Vec::new();
if let Some(defaults) = &self.defaults {
if !args.is_empty() {
result.push(args[0].clone()); }
let default_args = shell_words::split(defaults)
.with_context(|| "Invalid defaults: failed to parse arguments".to_string())?;
result.extend(default_args);
result.extend(args.into_iter().skip(1));
} else {
result = args;
}
let mut final_result = Vec::new();
let mut i = 0;
while i < result.len() {
if (result[i] == "-a" || result[i] == "--alias") && i + 1 < result.len() {
let name = &result[i + 1];
let mut seen = std::collections::HashSet::new();
let resolved = self.resolve_alias(name, &mut seen, 0)?;
final_result.extend(resolved);
i += 2;
} else {
final_result.push(result[i].clone());
i += 1;
}
}
Ok(final_result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_load_config_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "defaults = --format json --output-format csv").unwrap();
writeln!(file).unwrap();
writeln!(file, "[aliases]").unwrap();
writeln!(file, "errors = -l error").unwrap();
writeln!(file, "json-logs = --format json --output-format json").unwrap();
file.flush().unwrap();
let config = ConfigFile::load_from_path(&file.path().to_path_buf()).unwrap();
assert_eq!(
config.defaults,
Some("--format json --output-format csv".to_string())
);
assert_eq!(config.aliases.get("errors"), Some(&"-l error".to_string()));
assert_eq!(
config.aliases.get("json-logs"),
Some(&"--format json --output-format json".to_string())
);
}
#[test]
fn test_resolve_alias() {
let mut config = ConfigFile::default();
config
.aliases
.insert("errors".to_string(), "-l error".to_string());
config.aliases.insert(
"json-errors".to_string(),
"--format json -a errors".to_string(),
);
let mut seen = std::collections::HashSet::new();
let resolved = config.resolve_alias("json-errors", &mut seen, 0).unwrap();
assert_eq!(resolved, vec!["--format", "json", "-l", "error"]);
}
#[test]
fn test_circular_alias_detection() {
let mut config = ConfigFile::default();
config
.aliases
.insert("alias1".to_string(), "-a alias2".to_string());
config
.aliases
.insert("alias2".to_string(), "-a alias1".to_string());
let mut seen = std::collections::HashSet::new();
let result = config.resolve_alias("alias1", &mut seen, 0);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Circular dependency"));
}
#[test]
fn test_process_args() {
let mut config = ConfigFile::default();
config
.aliases
.insert("errors".to_string(), "-l error --stats".to_string());
let args = vec![
"kelora".to_string(),
"-a".to_string(),
"errors".to_string(),
"--format".to_string(),
"json".to_string(),
];
let processed = config.process_args(args).unwrap();
assert_eq!(
processed,
vec!["kelora", "-l", "error", "--stats", "--format", "json"]
);
}
#[test]
fn test_process_args_with_defaults() {
let config = ConfigFile {
defaults: Some("--stats --parallel".to_string()),
..ConfigFile::default()
};
let args = vec![
"kelora".to_string(),
"--format".to_string(),
"json".to_string(),
"input.log".to_string(),
];
let processed = config.process_args(args).unwrap();
assert_eq!(
processed,
vec![
"kelora",
"--stats",
"--parallel",
"--format",
"json",
"input.log"
]
);
}
#[test]
fn test_process_args_with_defaults_and_aliases() {
let mut config = ConfigFile {
defaults: Some("--stats".to_string()),
..ConfigFile::default()
};
config
.aliases
.insert("errors".to_string(), "-l error".to_string());
let args = vec![
"kelora".to_string(),
"-a".to_string(),
"errors".to_string(),
"--format".to_string(),
"json".to_string(),
];
let processed = config.process_args(args).unwrap();
assert_eq!(
processed,
vec!["kelora", "--stats", "-l", "error", "--format", "json"]
);
}
#[test]
fn test_project_config_discovery() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path().canonicalize().unwrap();
let subdir = project_root.join("src").join("deep");
std::fs::create_dir_all(&subdir).unwrap();
let config_path = project_root.join(".kelora.ini");
std::fs::write(&config_path, "defaults = --project-test").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&subdir).unwrap();
let found_config = ConfigFile::find_project_config();
std::env::set_current_dir(&original_dir).unwrap();
assert_eq!(found_config, Some(config_path));
}
#[test]
fn test_config_merging() {
let user_config = ConfigFile {
defaults: Some("--user-defaults".to_string()),
aliases: {
let mut aliases = HashMap::new();
aliases.insert("user-alias".to_string(), "--user-value".to_string());
aliases.insert("shared-alias".to_string(), "--user-shared".to_string());
aliases
},
};
let project_config = ConfigFile {
defaults: Some("--project-defaults".to_string()),
aliases: {
let mut aliases = HashMap::new();
aliases.insert("project-alias".to_string(), "--project-value".to_string());
aliases.insert("shared-alias".to_string(), "--project-shared".to_string());
aliases
},
};
let merged = ConfigFile::merge_configs(user_config, project_config);
assert_eq!(merged.defaults, Some("--project-defaults".to_string()));
assert_eq!(
merged.aliases.get("user-alias"),
Some(&"--user-value".to_string())
);
assert_eq!(
merged.aliases.get("project-alias"),
Some(&"--project-value".to_string())
);
assert_eq!(
merged.aliases.get("shared-alias"),
Some(&"--project-shared".to_string())
);
}
#[test]
fn test_config_merging_with_none_defaults() {
let base_config = ConfigFile {
defaults: Some("--base-defaults".to_string()),
aliases: HashMap::new(),
};
let overlay_config = ConfigFile {
defaults: None,
aliases: {
let mut aliases = HashMap::new();
aliases.insert("test-alias".to_string(), "--test-value".to_string());
aliases
},
};
let merged = ConfigFile::merge_configs(base_config, overlay_config);
assert_eq!(merged.defaults, Some("--base-defaults".to_string()));
assert_eq!(
merged.aliases.get("test-alias"),
Some(&"--test-value".to_string())
);
}
#[test]
fn test_project_config_not_found() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("no-config");
std::fs::create_dir_all(&subdir).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&subdir).unwrap();
let found_config = ConfigFile::find_project_config();
std::env::set_current_dir(&original_dir).unwrap();
assert_eq!(found_config, None);
}
#[test]
fn test_user_config_paths() {
let paths = ConfigFile::get_user_config_paths();
assert!(!paths.is_empty());
for path in &paths {
let file_name = path.file_name().unwrap().to_string_lossy();
assert_eq!(
file_name, "kelora.ini",
"Unexpected user config filename: {}",
file_name
);
let parent_path = path.parent().unwrap().to_string_lossy();
let is_config_dir = parent_path.contains("config") || parent_path.contains("APPDATA");
assert!(
is_config_dir,
"User config not in expected config directory: {}",
parent_path
);
}
}
#[test]
fn test_get_config_paths_precedence() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path().canonicalize().unwrap();
let config_path = project_root.join(".kelora.ini");
std::fs::write(&config_path, "test").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&project_root).unwrap();
let paths = ConfigFile::get_config_paths();
std::env::set_current_dir(&original_dir).unwrap();
assert_eq!(paths[0], config_path);
let user_paths = ConfigFile::get_user_config_paths();
assert_eq!(&paths[1..], &user_paths[..]);
}
}