use anyhow::Result;
use clap::error::{ContextKind, ContextValue, ErrorKind};
use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser};
use crate::cli::{Cli, OutputFormat, ShellCompletion};
use crate::config::MultilineJoin;
use crate::config_file::{ConfigExpansionInfo, ConfigFile};
use crate::help;
use crate::platform::{ExitCode, SafeStderr};
use crate::tty;
pub fn validate_cli_args(cli: &Cli) -> Result<()> {
if cli.no_input && !cli.files.is_empty() {
return Err(anyhow::anyhow!(
"--no-input cannot be used with input files. Remove --no-input to read files, or remove file arguments to run script-only stages."
));
}
let mut stdin_count = 0;
for file_path in &cli.files {
if file_path == "-" {
stdin_count += 1;
if stdin_count > 1 {
return Err(anyhow::anyhow!("stdin (\"-\") can only be specified once"));
}
}
}
for exec_file in &cli.exec_files {
if !std::path::Path::new(exec_file).exists() {
return Err(anyhow::anyhow!("Exec file not found: {}", exec_file));
}
}
if let Some(batch_size) = cli.batch_size {
if batch_size == 0 {
return Err(anyhow::anyhow!("Batch size must be greater than 0"));
}
}
if cli.threads > 1000 {
return Err(anyhow::anyhow!("Thread count too high (max 1000)"));
}
if cli.span_close.is_some() && cli.span.is_none() && cli.span_idle.is_none() {
return Err(anyhow::anyhow!(
"--span-close requires --span or --span-idle. Use --span N for fixed-size spans or --span-idle 30s for inactivity-based spans."
));
}
if cli.multiline.is_none() && cli.multiline_join != MultilineJoin::Space {
return Err(anyhow::anyhow!(
"--multiline-join requires --multiline. Start with --multiline indent, --multiline blank, or see --help-multiline for regex/timestamp strategies."
));
}
if cli.core {
match cli.output_format {
OutputFormat::Csv => {
return Err(anyhow::anyhow!(
"csv output format does not support --core. Use --keys to define column order, e.g. --keys ts,level,msg."
));
}
OutputFormat::Tsv => {
return Err(anyhow::anyhow!(
"tsv output format does not support --core. Use --keys to define column order, e.g. --keys ts,level,msg."
));
}
OutputFormat::Csvnh => {
return Err(anyhow::anyhow!(
"csvnh output format does not support --core. Use --keys to define column order, e.g. --keys ts,level,msg."
));
}
OutputFormat::Tsvnh => {
return Err(anyhow::anyhow!(
"tsvnh output format does not support --core. Use --keys to define column order, e.g. --keys ts,level,msg."
));
}
_ => {
}
}
}
let implies_parallel = cli.parallel || cli.threads > 0 || cli.batch_size.is_some();
if (cli.discover_fields.is_some() || cli.discover_final_fields.is_some()) && implies_parallel {
return Err(anyhow::anyhow!(
"--discover and --discover-final are not supported with --parallel or thread overrides. Rerun without --parallel to use field discovery."
));
}
if cli.drain.is_some() && implies_parallel {
return Err(anyhow::anyhow!(
"--drain summary is not supported with --parallel or thread overrides. Rerun without --parallel to use Drain template mining."
));
}
if cli.drain.is_some() {
let effective_keys: Vec<String> = cli
.keys
.iter()
.filter(|key| !cli.exclude_keys.contains(key))
.cloned()
.collect();
if effective_keys.len() != 1 {
return Err(anyhow::anyhow!(
"--drain requires exactly one effective field in --keys after exclusions, e.g. --keys msg. Use -s to inspect available fields."
));
}
}
Ok(())
}
pub fn extract_config_file_arg(args: &[String]) -> Option<String> {
for i in 0..args.len() {
if args[i] == "--config-file" && i + 1 < args.len() {
return Some(args[i + 1].clone());
}
}
None
}
pub fn extract_save_alias_arg(args: &[String]) -> Option<String> {
for i in 0..args.len() {
if args[i] == "--save-alias" && i + 1 < args.len() {
return Some(args[i + 1].clone());
}
}
None
}
fn extract_completions_arg(args: &[String]) -> Option<ShellCompletion> {
for i in 0..args.len() {
if args[i] == "--completions" && i + 1 < args.len() {
return match args[i + 1].to_lowercase().as_str() {
"bash" => Some(ShellCompletion::Bash),
"zsh" => Some(ShellCompletion::Zsh),
"fish" => Some(ShellCompletion::Fish),
"powershell" => Some(ShellCompletion::PowerShell),
"elvish" => Some(ShellCompletion::Elvish),
_ => None,
};
}
if let Some(shell) = args[i].strip_prefix("--completions=") {
return match shell.to_lowercase().as_str() {
"bash" => Some(ShellCompletion::Bash),
"zsh" => Some(ShellCompletion::Zsh),
"fish" => Some(ShellCompletion::Fish),
"powershell" => Some(ShellCompletion::PowerShell),
"elvish" => Some(ShellCompletion::Elvish),
_ => None,
};
}
}
None
}
fn generate_completions(shell: ShellCompletion) {
use clap_complete::{generate, Shell};
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
let shell = match shell {
ShellCompletion::Bash => Shell::Bash,
ShellCompletion::Zsh => Shell::Zsh,
ShellCompletion::Fish => Shell::Fish,
ShellCompletion::PowerShell => Shell::PowerShell,
ShellCompletion::Elvish => Shell::Elvish,
};
generate(shell, &mut cmd, name, &mut std::io::stdout());
}
pub fn should_resolve_alias_references(args: &[String], alias_name: &str) -> bool {
let mut i = 0;
while i < args.len() {
if (args[i] == "-a" || args[i] == "--alias") && i + 1 < args.len() {
if args[i + 1] == alias_name {
return true;
}
i += 2;
} else {
i += 1;
}
}
false
}
fn strip_positional_files(command_args: &mut Vec<String>) -> Vec<String> {
let mut argv: Vec<String> = Vec::with_capacity(command_args.len() + 1);
argv.push("kelora".to_string());
argv.extend(command_args.iter().cloned());
let files = match Cli::try_parse_from(&argv) {
Ok(cli) => cli.files,
Err(_) => return Vec::new(),
};
let mut dropped = Vec::new();
for file in files {
if file == "-" {
continue;
}
if let Some(pos) = command_args.iter().rposition(|a| a == &file) {
command_args.remove(pos);
dropped.push(file);
}
}
dropped
}
pub fn handle_save_alias(raw_args: &[String], alias_name: &str, use_emoji: bool) {
let mut config_file_path: Option<String> = None;
let mut command_args = Vec::new();
let mut i = 0;
while i < raw_args.len() {
if raw_args[i] == "--save-alias" {
i += 2;
} else if raw_args[i] == "--config-file" && i + 1 < raw_args.len() {
config_file_path = Some(raw_args[i + 1].clone());
i += 2;
} else {
command_args.push(raw_args[i].clone());
i += 1;
}
}
if !command_args.is_empty() {
command_args.remove(0);
}
let dropped_files = strip_positional_files(&mut command_args);
if !dropped_files.is_empty() {
let prefix = if use_emoji { "🔹" } else { "kelora:" };
eprintln!(
"{} Not saving input file(s) in alias '{}' (aliases store reusable options, not specific inputs): {}",
prefix,
alias_name,
dropped_files.join(", ")
);
}
if command_args.is_empty() {
let prefix = if use_emoji { "⚠️" } else { "kelora:" };
if dropped_files.is_empty() {
eprintln!("{} No command to save as alias '{}'", prefix, alias_name);
} else {
eprintln!(
"{} No options to save as alias '{}' (only input files were given)",
prefix, alias_name
);
}
std::process::exit(2);
}
let should_resolve = should_resolve_alias_references(&command_args, alias_name);
let alias_value = if command_args
.iter()
.any(|arg| arg == "-a" || arg == "--alias")
{
let config_result = match config_file_path.as_ref() {
Some(path) => ConfigFile::load_with_custom_path(Some(path)),
None => ConfigFile::load_with_custom_path(None),
};
match config_result {
Ok((config, _path)) => {
if should_resolve {
match config.resolve_args_only(&command_args) {
Ok(resolved_args) => {
if resolved_args.is_empty() {
let prefix = if use_emoji { "⚠️" } else { "kelora:" };
eprintln!(
"{} Resolved command is empty for alias '{}'",
prefix, alias_name
);
std::process::exit(2);
}
shell_words::join(resolved_args)
}
Err(e) => {
let prefix = if use_emoji { "⚠️" } else { "kelora:" };
eprintln!("{} Failed to resolve aliases in command: {}", prefix, e);
std::process::exit(1);
}
}
} else {
if let Err(e) = config.validate_alias_references(&command_args) {
let prefix = if use_emoji { "⚠️" } else { "kelora:" };
eprintln!("{} {}", prefix, e);
eprintln!(
"{} Cannot save alias '{}' with reference to non-existent alias",
prefix, alias_name
);
std::process::exit(1);
}
shell_words::join(command_args)
}
}
Err(_) if should_resolve => {
let prefix = if use_emoji { "⚠️" } else { "kelora:" };
eprintln!(
"{} Cannot update alias '{}' - no config file found",
prefix, alias_name
);
eprintln!(
"{} To create a new alias, use a command without referencing itself",
prefix
);
std::process::exit(1);
}
Err(_) => {
let prefix = if use_emoji { "⚠️" } else { "kelora:" };
eprintln!(
"{} Cannot save alias '{}' with alias references - no config file found",
prefix, alias_name
);
eprintln!(
"{} Create the referenced aliases first, or use a command without alias references",
prefix
);
std::process::exit(1);
}
}
} else {
shell_words::join(command_args)
};
let target_path = config_file_path.as_ref().map(std::path::Path::new);
match ConfigFile::save_alias(alias_name, &alias_value, target_path) {
Ok((config_path, previous_value)) => {
let success_prefix = if use_emoji { "🔹" } else { "kelora:" };
println!(
"{} Alias '{}' saved to {}",
success_prefix,
alias_name,
config_path.display()
);
if let Some(prev) = previous_value {
let info_prefix = if use_emoji { "🔹" } else { "kelora:" };
println!("{} Replaced previous alias:", info_prefix);
println!(" {} = {}", alias_name, prev);
}
}
Err(e) => {
let error_prefix = if use_emoji { "⚠️" } else { "kelora:" };
eprintln!(
"{} Failed to save alias '{}': {}",
error_prefix, alias_name, e
);
std::process::exit(1);
}
}
}
fn invalid_arg(e: &clap::Error) -> Option<String> {
match e.get(ContextKind::InvalidArg)? {
ContextValue::String(s) => Some(s.clone()),
ContextValue::Strings(ss) => ss.first().cloned(),
_ => None,
}
}
fn unknown_arg_hint(arg: &str) -> Option<String> {
let name = arg
.trim_start_matches('-')
.split('=')
.next()
.unwrap_or("")
.to_ascii_lowercase();
let hint = match name.as_str() {
"where" | "grep" | "match" => {
"kelora has no --where/--grep flag. Filter events with --filter:\n \
kelora --filter 'e.level == \"ERROR\"' app.log\n \
See --help-rhai for expression syntax."
}
"sort" | "top-n" | "topn" | "rank" | "nlargest" => {
"kelora has no --sort/--rank flag. To rank by a score, use track_top_by in a script stage:\n \
kelora -m --exec 'track_top_by(\"slowest\", e.endpoint, e.latency_ms, 10)' app.log\n \
For a frequency top-N, use --freq FIELD (sorted by count, so pipe to head/tail). See --help-functions for details."
}
"count" => {
"kelora has no --count flag — \"count\" is ambiguous between a running total and a per-value tally. For a frequency table (\"count by\"), use --freq FIELD:\n \
kelora --freq level app.log\n \
It is shorthand for track_freq(\"level\", e.level). See --help-functions for details."
}
"uniq" | "uniq-c" | "group-by" | "groupby" => {
"kelora has no --group-by/--uniq flag. To aggregate by a category, use track_freq in a script stage:\n \
kelora -m --exec 'track_freq(\"level\", e.level)' app.log\n \
For a quick frequency table, use --freq FIELD. See --help-functions for details."
}
_ => return None,
};
Some(format!(
"error: unexpected argument '{arg}'\n\nhint: {hint}\n\n{}\n\nFor a quick reference, try '-h'.",
Cli::command().render_usage()
))
}
pub fn process_args_with_config(stderr: &mut SafeStderr) -> (ArgMatches, Cli, ConfigExpansionInfo) {
let raw_args: Vec<String> = std::env::args().collect();
let config_file_path = extract_config_file_arg(&raw_args);
let has_show_config = raw_args.iter().any(|arg| arg == "--show-config");
let has_edit_config = raw_args.iter().any(|arg| arg == "--edit-config");
let has_ignore_config = raw_args.iter().any(|arg| arg == "--ignore-config");
if has_show_config && has_edit_config {
stderr
.writeln("kelora: Error: --show-config and --edit-config are mutually exclusive")
.unwrap_or(());
ExitCode::InvalidUsage.exit();
}
if has_ignore_config && has_edit_config {
stderr
.writeln("kelora: Error: --ignore-config and --edit-config are mutually exclusive")
.unwrap_or(());
ExitCode::InvalidUsage.exit();
}
if has_show_config {
ConfigFile::show_config();
std::process::exit(0);
}
if has_edit_config {
ConfigFile::edit_config(config_file_path.as_deref());
std::process::exit(0);
}
if raw_args.iter().any(|arg| arg == "--help-time") {
help::print_time_format_help();
std::process::exit(0);
}
if let Some(pos) = raw_args
.iter()
.position(|arg| arg == "--help-functions" || arg.starts_with("--help-functions="))
{
let arg = &raw_args[pos];
let keyword = if let Some(kw) = arg.strip_prefix("--help-functions=") {
(!kw.is_empty()).then(|| kw.to_string())
} else {
raw_args
.get(pos + 1)
.filter(|next| !next.starts_with('-'))
.map(|next| next.to_string())
};
help::print_functions_help(keyword.as_deref());
std::process::exit(0);
}
if let Some(pos) = raw_args
.iter()
.position(|arg| arg == "--help" || arg.starts_with("--help="))
{
let arg = &raw_args[pos];
let keyword = if let Some(kw) = arg.strip_prefix("--help=") {
(!kw.is_empty()).then(|| kw.to_string())
} else {
raw_args.get(pos + 1).map(|next| next.to_string())
};
if let Some(keyword) = keyword {
help::print_cli_help_filtered(&keyword);
std::process::exit(0);
}
}
if raw_args.iter().any(|arg| arg == "-h") {
help::print_quick_help();
std::process::exit(0);
}
if raw_args.iter().any(|arg| arg == "--help-examples") {
help::print_examples_help();
std::process::exit(0);
}
if raw_args.iter().any(|arg| arg == "--help-rhai") {
help::print_rhai_help();
std::process::exit(0);
}
if raw_args.iter().any(|arg| arg == "--help-multiline") {
help::print_multiline_help();
std::process::exit(0);
}
if raw_args.iter().any(|arg| arg == "--help-regex") {
help::print_regex_help();
std::process::exit(0);
}
if raw_args.iter().any(|arg| arg == "--help-formats") {
help::print_formats_help();
std::process::exit(0);
}
if let Some(shell) = extract_completions_arg(&raw_args) {
generate_completions(shell);
std::process::exit(0);
}
if let Some(alias_name) = extract_save_alias_arg(&raw_args) {
let use_emoji = tty::should_use_emoji_for_stderr();
handle_save_alias(&raw_args, &alias_name, use_emoji);
std::process::exit(0);
}
let ignore_config = has_ignore_config;
let disable_auto_config = std::env::var_os("KELORA_IGNORE_CONFIG").is_some();
let (processed_args, expansion_info) = if ignore_config {
(raw_args, ConfigExpansionInfo::default())
} else if let Some(path) = config_file_path.as_deref() {
match ConfigFile::load_with_custom_path(Some(path)) {
Ok((config_file, loaded_path)) => match config_file.process_args(raw_args) {
Ok((processed, mut info)) => {
info.loaded_config_path = loaded_path;
(processed, info)
}
Err(e) => {
stderr
.writeln(&format!("kelora: Config error: {}", e))
.unwrap_or(());
ExitCode::InvalidUsage.exit();
}
},
Err(e) => {
stderr
.writeln(&format!("kelora: Config file error: {}", e))
.unwrap_or(());
ExitCode::InvalidUsage.exit();
}
}
} else if disable_auto_config {
(raw_args, ConfigExpansionInfo::default())
} else {
match ConfigFile::load_with_custom_path(config_file_path.as_deref()) {
Ok((config_file, loaded_path)) => match config_file.process_args(raw_args) {
Ok((processed, mut info)) => {
info.loaded_config_path = loaded_path;
(processed, info)
}
Err(e) => {
stderr
.writeln(&format!("kelora: Config error: {}", e))
.unwrap_or(());
ExitCode::InvalidUsage.exit();
}
},
Err(e) => {
stderr
.writeln(&format!("kelora: Config file error: {}", e))
.unwrap_or(());
ExitCode::InvalidUsage.exit();
}
}
};
let matches = match Cli::command().try_get_matches_from(processed_args) {
Ok(matches) => matches,
Err(e) => {
if e.kind() == ErrorKind::UnknownArgument {
if let Some(hint) = invalid_arg(&e).and_then(|arg| unknown_arg_hint(&arg)) {
stderr.writeln(&hint).unwrap_or(());
ExitCode::InvalidUsage.exit();
}
}
e.exit();
}
};
let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| {
stderr
.writeln(&format!("kelora: Error: {}", e))
.unwrap_or(());
std::process::exit(1);
});
cli.resolve_boolean_flags();
let trim_key_list = |keys: &[String]| -> Vec<String> {
keys.iter()
.map(|k| k.trim())
.filter(|k| !k.is_empty())
.map(|k| k.to_string())
.collect()
};
cli.keys = trim_key_list(&cli.keys);
cli.exclude_keys = trim_key_list(&cli.exclude_keys);
if crate::tty::is_stdin_tty() && cli.files.is_empty() && !cli.no_input {
let raw_args: Vec<String> = std::env::args().collect();
if raw_args.len() == 1 {
if let Err(e) = crate::interactive::run_interactive_mode() {
eprintln!("Interactive mode error: {}", e);
std::process::exit(1);
}
std::process::exit(0);
}
eprintln!("error: no input files or stdin provided");
eprintln!();
eprintln!("{}", Cli::command().render_usage());
eprintln!();
eprintln!(
"Pass one or more files, pipe input on stdin, or run plain 'kelora' to enter interactive mode."
);
eprintln!("For a quick reference, try '-h'.");
std::process::exit(2);
}
(matches, cli, expansion_info)
}