#![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: HashMap<String, String>,
pub aliases: HashMap<String, String>,
}
impl ConfigFile {
pub fn get_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("config.ini"));
}
if let Ok(userprofile) = env::var("USERPROFILE") {
paths.push(PathBuf::from(userprofile).join(".kelorarc"));
}
} else {
let xdg_config = 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(xdg_config.join("kelora").join("config.ini"));
if let Ok(home) = env::var("HOME") {
paths.push(PathBuf::from(home).join(".kelorarc"));
}
}
paths
}
pub fn find_config_path() -> Option<PathBuf> {
Self::get_config_paths().into_iter().find(|p| p.exists())
}
pub fn load() -> Result<Self> {
let config_file = Self::find_config_path();
if let Some(path) = config_file {
Self::load_from_path(&path)
} else {
Ok(Self::default())
}
}
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 = HashMap::new();
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();
match current_section.as_str() {
"defaults" => {
let normalized_key = key.replace('-', "_");
defaults.insert(normalized_key, value.to_string());
}
"aliases" => {
aliases.insert(key.to_string(), value.to_string());
}
_ => {
}
}
}
}
Ok(Self { defaults, aliases })
}
pub fn show_config() {
let config_file = Self::find_config_path();
if let Some(path) = config_file {
match Self::load_from_path(&path) {
Ok(config) => {
println!("Configuration loaded from: {}", path.display());
if !config.defaults.is_empty() {
println!("\nDefaults:");
let mut sorted_defaults: Vec<_> = config.defaults.iter().collect();
sorted_defaults.sort_by_key(|(k, _)| k.as_str());
for (key, value) in sorted_defaults {
println!(" {} = {}", key, value);
}
}
if !config.aliases.is_empty() {
println!("\nAliases:");
let mut sorted_aliases: Vec<_> = 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 config file: {}", e);
}
}
} else {
println!("No config file found. Searched locations:");
for path in Self::get_config_paths() {
println!(" {}", path.display());
}
println!("\nCreate a config file at any of these locations. Example:");
println!();
println!("[defaults]");
println!("input-format = auto");
println!("output-format = default");
println!("skip-lines = 0");
println!("parallel = false");
println!("stats = true");
println!("verbose = false");
println!("input-tz = UTC");
println!();
println!("[aliases]");
println!("errors = --filter 'e.level == \"error\"' --stats");
println!(
"json-errors = --format jsonl --filter 'e.level == \"error\"' --output-format jsonl"
);
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();
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)
}
}
#[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]").unwrap();
writeln!(file, "format = jsonl").unwrap();
writeln!(file, "output-format = csv").unwrap();
writeln!(file).unwrap();
writeln!(file, "[aliases]").unwrap();
writeln!(file, "errors = --filter 'e.level == \"error\"'").unwrap();
writeln!(file, "json-logs = --format jsonl --output-format jsonl").unwrap();
file.flush().unwrap();
let config = ConfigFile::load_from_path(&file.path().to_path_buf()).unwrap();
assert_eq!(config.defaults.get("format"), Some(&"jsonl".to_string()));
assert_eq!(
config.defaults.get("output_format"),
Some(&"csv".to_string())
);
assert_eq!(
config.aliases.get("errors"),
Some(&"--filter 'e.level == \"error\"'".to_string())
);
assert_eq!(
config.aliases.get("json-logs"),
Some(&"--format jsonl --output-format jsonl".to_string())
);
}
#[test]
fn test_resolve_alias() {
let mut config = ConfigFile::default();
config.aliases.insert(
"errors".to_string(),
"--filter 'e.level == \"error\"'".to_string(),
);
config.aliases.insert(
"json-errors".to_string(),
"--format jsonl -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", "jsonl", "--filter", "e.level == \"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(),
"--filter 'e.level == \"error\"' --stats".to_string(),
);
let args = vec![
"kelora".to_string(),
"-a".to_string(),
"errors".to_string(),
"--format".to_string(),
"jsonl".to_string(),
];
let processed = config.process_args(args).unwrap();
assert_eq!(
processed,
vec![
"kelora",
"--filter",
"e.level == \"error\"",
"--stats",
"--format",
"jsonl"
]
);
}
}