#![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>,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigExpansionInfo {
pub loaded_config_path: Option<PathBuf>,
pub explicit_config_path: bool,
pub applied_defaults: Option<String>,
pub expanded_aliases: Vec<(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").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").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, Option<PathBuf>)> {
let mut config = Self::default();
let mut loaded_path: Option<PathBuf> = None;
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);
loaded_path = Some(path);
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);
loaded_path = Some(project_path); }
Ok((config, loaded_path))
}
pub fn load_with_custom_path(custom_path: Option<&str>) -> Result<(Self, Option<PathBuf>)> {
if let Some(path) = custom_path {
let path_buf = std::path::PathBuf::from(path);
let config = Self::load_from_path(&path_buf)?;
Ok((config, Some(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() {
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());
let config_path = project_config_path.as_ref().or(user_config_path);
if let Some(path) = config_path {
println!("# {}", path.display());
println!();
match std::fs::read_to_string(path) {
Ok(content) => print!("{}", content),
Err(e) => eprintln!("Error reading config: {}", e),
}
} else {
println!(
"No config found (searched: .kelora.ini from cwd upward, {})",
user_config_paths[0].display()
);
println!();
println!("Example .kelora.ini:");
println!("defaults = -f auto --stats");
println!();
println!("[aliases]");
println!("errors = -l error --stats");
println!("json-errors = -f json -l error -F json");
println!("slow-requests = --filter 'e.response_time.to_int() > 1000' --keys timestamp,method,path,response_time");
}
}
pub fn edit_config(custom_path: Option<&str>) {
use std::fs;
use std::process::Command;
let config_path = if let Some(path) = custom_path {
PathBuf::from(path)
} else {
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());
project_config_path
.or_else(|| user_config_path.cloned())
.unwrap_or_else(|| user_config_paths[0].clone())
};
if let Some(parent) = config_path.parent() {
if !parent.exists() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!(
"kelora: Failed to create directory {}: {}",
parent.display(),
e
);
std::process::exit(1);
}
}
}
let editor = if cfg!(windows) {
env::var("EDITOR").unwrap_or_else(|_| "notepad.exe".to_string())
} else {
env::var("EDITOR").unwrap_or_else(|_| "vi".to_string())
};
println!("Opening {} in {}...", config_path.display(), editor);
match Command::new(&editor).arg(&config_path).status() {
Ok(status) => {
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
}
Err(e) => {
eprintln!("kelora: Failed to launch editor '{}': {}", editor, e);
eprintln!("You can set the EDITOR environment variable to your preferred editor.");
std::process::exit(1);
}
}
}
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 has_alias(&self, name: &str) -> bool {
self.aliases.contains_key(name)
}
pub fn process_args(&self, args: Vec<String>) -> Result<(Vec<String>, ConfigExpansionInfo)> {
let mut info = ConfigExpansionInfo::default();
let mut result = Vec::new();
if let Some(defaults) = &self.defaults {
info.applied_defaults = Some(defaults.clone());
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].clone();
let mut seen = std::collections::HashSet::new();
let resolved = self.resolve_alias(&name, &mut seen, 0)?;
let expansion = resolved.join(" ");
info.expanded_aliases.push((name, expansion));
final_result.extend(resolved);
i += 2;
} else {
final_result.push(result[i].clone());
i += 1;
}
}
Ok((final_result, info))
}
pub fn resolve_args_only(&self, args: &[String]) -> Result<Vec<String>> {
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 name = &args[i + 1];
let mut seen = std::collections::HashSet::new();
let resolved = self.resolve_alias(name, &mut seen, 0)?;
result.extend(resolved);
i += 2;
} else {
result.push(args[i].clone());
i += 1;
}
}
Ok(result)
}
pub fn validate_alias_references(&self, args: &[String]) -> Result<()> {
let mut i = 0;
while i < args.len() {
if (args[i] == "-a" || args[i] == "--alias") && i + 1 < args.len() {
let name = &args[i + 1];
if !self.has_alias(name) {
return Err(anyhow!(
"Referenced alias '{}' does not exist in config",
name
));
}
i += 2;
} else {
i += 1;
}
}
Ok(())
}
pub fn write_to_path(&self, path: &std::path::Path) -> Result<()> {
use std::fs;
use std::io::Write;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let mut content = String::new();
if let Some(defaults) = &self.defaults {
content.push_str(&format!("defaults = {}\n", defaults));
}
if !self.aliases.is_empty() {
if !content.is_empty() {
content.push('\n');
}
content.push_str("[aliases]\n");
let mut sorted_aliases: Vec<_> = self.aliases.iter().collect();
sorted_aliases.sort_by_key(|(k, _)| k.as_str());
for (name, value) in sorted_aliases {
content.push_str(&format!("{} = {}\n", name, value));
}
}
let temp_path = path.with_extension("tmp");
{
let mut file = fs::File::create(&temp_path).with_context(|| {
format!("Failed to create temporary file: {}", temp_path.display())
})?;
file.write_all(content.as_bytes()).with_context(|| {
format!("Failed to write to temporary file: {}", temp_path.display())
})?;
file.sync_all().with_context(|| {
format!("Failed to sync temporary file: {}", temp_path.display())
})?;
}
fs::rename(&temp_path, path)
.with_context(|| format!("Failed to rename temporary file to: {}", path.display()))?;
Ok(())
}
pub fn save_alias(
alias_name: &str,
alias_value: &str,
target_path: Option<&std::path::Path>,
) -> Result<(PathBuf, Option<String>)> {
let alias_regex = regex::Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$").unwrap();
if !alias_regex.is_match(alias_name) {
return Err(anyhow!(
"Invalid alias name '{}'. Must match pattern: ^[a-zA-Z_][a-zA-Z0-9_-]{{0,63}}$",
alias_name
));
}
let config_path = if let Some(path) = target_path {
path.to_path_buf()
} else {
if let Some(project_path) = Self::find_project_config() {
project_path
} else if let Some(user_path) =
Self::get_user_config_paths().iter().find(|p| p.exists())
{
user_path.clone()
} else {
Self::get_user_config_paths()[0].clone()
}
};
let (previous_value, new_content) = if config_path.exists() {
use std::fs;
let content = fs::read_to_string(&config_path).with_context(|| {
format!("Failed to read config file: {}", config_path.display())
})?;
Self::update_alias_in_content(&content, alias_name, alias_value)?
} else {
let content = format!("[aliases]\n{} = {}\n", alias_name, alias_value);
(None, content)
};
use std::fs;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
fs::write(&config_path, new_content.as_bytes())
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
Ok((config_path, previous_value))
}
fn update_alias_in_content(
content: &str,
alias_name: &str,
alias_value: &str,
) -> Result<(Option<String>, String)> {
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let mut in_aliases_section = false;
let mut aliases_section_start: Option<usize> = None;
let mut alias_line_index: Option<usize> = None;
let mut previous_value: Option<String> = None;
let mut last_section_line: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if in_aliases_section {
last_section_line = Some(i - 1);
break;
}
let section_name = &trimmed[1..trimmed.len() - 1];
if section_name == "aliases" {
in_aliases_section = true;
aliases_section_start = Some(i);
}
continue;
}
if in_aliases_section
&& !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with(';')
{
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
if key == alias_name {
let value = trimmed[eq_pos + 1..].trim();
previous_value = Some(value.to_string());
alias_line_index = Some(i);
break;
}
}
}
}
if in_aliases_section && last_section_line.is_none() {
last_section_line = Some(lines.len() - 1);
}
let new_alias_line = format!("{} = {}", alias_name, alias_value);
if let Some(idx) = alias_line_index {
lines[idx] = new_alias_line;
} else if let Some(section_start) = aliases_section_start {
let insert_pos = last_section_line
.map(|pos| pos + 1)
.unwrap_or(section_start + 1);
lines.insert(insert_pos, new_alias_line);
} else {
if !lines.is_empty() && !lines[lines.len() - 1].is_empty() {
lines.push(String::new()); }
lines.push("[aliases]".to_string());
lines.push(new_alias_line);
}
let mut result = lines.join("\n");
if !result.ends_with('\n') {
result.push('\n');
}
Ok((previous_value, result))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::Path;
use tempfile::NamedTempFile;
#[test]
fn test_load_config_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "defaults = -f json -F csv").unwrap();
writeln!(file).unwrap();
writeln!(file, "[aliases]").unwrap();
writeln!(file, "errors = -l error").unwrap();
writeln!(file, "json-logs = -f json -F json").unwrap();
file.flush().unwrap();
let config = ConfigFile::load_from_path(&file.path().to_path_buf()).unwrap();
assert_eq!(config.defaults, Some("-f json -F csv".to_string()));
assert_eq!(config.aliases.get("errors"), Some(&"-l error".to_string()));
assert_eq!(
config.aliases.get("json-logs"),
Some(&"-f json -F 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(), "-f 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!["-f", "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(),
"-f".to_string(),
"json".to_string(),
];
let (processed, _info) = config.process_args(args).unwrap();
assert_eq!(
processed,
vec!["kelora", "-l", "error", "--stats", "-f", "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(),
"-f".to_string(),
"json".to_string(),
"input.log".to_string(),
];
let (processed, _info) = config.process_args(args).unwrap();
assert_eq!(
processed,
vec!["kelora", "--stats", "--parallel", "-f", "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(),
"-f".to_string(),
"json".to_string(),
];
let (processed, _info) = config.process_args(args).unwrap();
assert_eq!(
processed,
vec!["kelora", "--stats", "-l", "error", "-f", "json"]
);
}
use once_cell::sync::Lazy;
use std::sync::{Mutex, MutexGuard};
static CWD_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
struct WorkingDirGuard {
original_dir: PathBuf,
_lock: MutexGuard<'static, ()>,
}
impl WorkingDirGuard {
fn new(new_dir: &Path) -> std::io::Result<Self> {
let lock = CWD_MUTEX.lock().unwrap();
let original_dir = std::env::current_dir()?;
if let Err(err) = std::env::set_current_dir(new_dir) {
drop(lock);
return Err(err);
}
Ok(Self {
original_dir,
_lock: lock,
})
}
}
impl Drop for WorkingDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original_dir);
}
}
#[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 _guard = WorkingDirGuard::new(&subdir).unwrap();
let found_config = ConfigFile::find_project_config();
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 _guard = WorkingDirGuard::new(&subdir).unwrap();
let found_config = ConfigFile::find_project_config();
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_dir = path.parent().unwrap();
let parent_name = parent_dir.file_name().unwrap().to_string_lossy();
assert_eq!(
parent_name, "kelora",
"Parent directory should be 'kelora', got: {}",
parent_name
);
let grandparent_path = parent_dir.parent().unwrap().to_string_lossy();
let is_config_dir =
grandparent_path.contains("config") || grandparent_path.contains("APPDATA");
assert!(
is_config_dir,
"Grandparent not in expected config directory: {}",
grandparent_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 _guard = WorkingDirGuard::new(&project_root).unwrap();
let paths = ConfigFile::get_config_paths();
assert_eq!(paths[0], config_path);
let user_paths = ConfigFile::get_user_config_paths();
assert_eq!(&paths[1..], &user_paths[..]);
}
}