use crate::config::ScriptStageType;
use anyhow::Result;
use clap::{ArgMatches, Parser};
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum InputFormat {
Auto,
Json,
Line,
Raw,
Logfmt,
Syslog,
Cef,
Csv,
Tsv,
Csvnh,
Tsvnh,
Combined,
}
#[derive(clap::ValueEnum, Clone, Debug, Default)]
pub enum OutputFormat {
Json,
#[default]
Default,
Logfmt,
Inspect,
Levelmap,
Csv,
Tsv,
Csvnh,
Tsvnh,
None,
}
#[derive(clap::ValueEnum, Clone, Debug)]
pub enum FileOrder {
Cli,
Name,
Mtime,
}
#[derive(Parser)]
#[command(name = "kelora")]
#[command(about = "A command-line log analysis tool with embedded Rhai scripting")]
#[command(
long_about = "A command-line log analysis tool with embedded Rhai scripting\n\nMODES:\n (default) Sequential processing - best for streaming/interactive use\n --parallel Parallel processing - best for high-throughput batch analysis"
)]
#[command(author = "Dirk Loss <mail@dirk-loss.de>")]
#[command(version)]
#[command(args_override_self = true)]
pub struct Cli {
pub files: Vec<String>,
#[arg(
short = 'f',
long = "format",
value_enum,
default_value = "line",
help_heading = "Input Options"
)]
pub format: InputFormat,
#[arg(short = 'j', help_heading = "Input Options", conflicts_with = "format")]
pub json_input: bool,
#[arg(
long = "file-order",
value_enum,
default_value = "cli",
help_heading = "Input Options"
)]
pub file_order: FileOrder,
#[arg(long = "skip-lines", help_heading = "Input Options")]
pub skip_lines: Option<usize>,
#[arg(long = "keep-lines", help_heading = "Input Options")]
pub keep_lines: Option<String>,
#[arg(long = "ignore-lines", help_heading = "Input Options")]
pub ignore_lines: Option<String>,
#[arg(long = "ts-field", help_heading = "Input Options")]
pub ts_field: Option<String>,
#[arg(long = "ts-format", help_heading = "Input Options")]
pub ts_format: Option<String>,
#[arg(long = "input-tz", help_heading = "Input Options")]
pub input_tz: Option<String>,
#[arg(short = 'M', long = "multiline", help_heading = "Input Options")]
pub multiline: Option<String>,
#[arg(long = "extract-prefix", help_heading = "Input Options")]
pub extract_prefix: Option<String>,
#[arg(
long = "prefix-sep",
default_value = "|",
help_heading = "Input Options"
)]
pub prefix_sep: String,
#[arg(long = "begin", help_heading = "Processing Options")]
pub begin: Option<String>,
#[arg(long = "filter", help_heading = "Processing Options")]
pub filters: Vec<String>,
#[arg(short = 'e', long = "exec", help_heading = "Processing Options")]
pub execs: Vec<String>,
#[arg(short = 'E', long = "exec-file", help_heading = "Processing Options")]
pub exec_files: Vec<String>,
#[arg(long = "end", help_heading = "Processing Options")]
pub end: Option<String>,
#[arg(long = "window", help_heading = "Processing Options")]
pub window_size: Option<usize>,
#[arg(long = "strict", help_heading = "Error Handling")]
pub strict: bool,
#[arg(
long = "no-strict",
help_heading = "Error Handling",
overrides_with = "strict"
)]
pub no_strict: bool,
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, help_heading = "Error Handling")]
pub verbose: u8,
#[arg(short = 'q', long = "quiet", action = clap::ArgAction::Count, help_heading = "Error Handling")]
pub quiet: u8,
#[arg(
short = 'l',
long = "levels",
value_delimiter = ',',
help_heading = "Filtering Options"
)]
pub levels: Vec<String>,
#[arg(
short = 'L',
long = "exclude-levels",
value_delimiter = ',',
help_heading = "Filtering Options"
)]
pub exclude_levels: Vec<String>,
#[arg(
short = 'k',
long = "keys",
value_delimiter = ',',
help_heading = "Filtering Options"
)]
pub keys: Vec<String>,
#[arg(
short = 'K',
long = "exclude-keys",
value_delimiter = ',',
help_heading = "Filtering Options"
)]
pub exclude_keys: Vec<String>,
#[arg(long = "since", help_heading = "Filtering Options")]
pub since: Option<String>,
#[arg(long = "until", help_heading = "Filtering Options")]
pub until: Option<String>,
#[arg(long = "take", help_heading = "Filtering Options")]
pub take: Option<usize>,
#[arg(
short = 'F',
long = "output-format",
value_enum,
default_value = "default",
help_heading = "Output Options"
)]
pub output_format: OutputFormat,
#[arg(
short = 'J',
help_heading = "Output Options",
conflicts_with = "output_format"
)]
pub json_output: bool,
#[arg(short = 'c', long = "core", help_heading = "Output Options")]
pub core: bool,
#[arg(short = 'o', long = "output-file", help_heading = "Output Options")]
pub output_file: Option<String>,
#[arg(short = 'b', long = "brief", help_heading = "Default Format Options")]
pub brief: bool,
#[arg(long = "wrap", help_heading = "Default Format Options")]
pub wrap: bool,
#[arg(
long = "no-wrap",
help_heading = "Default Format Options",
overrides_with = "wrap"
)]
pub no_wrap: bool,
#[arg(long = "pretty-ts", help_heading = "Default Format Options")]
pub pretty_ts: Option<String>,
#[arg(short = 'z', help_heading = "Default Format Options")]
pub format_timestamps_local: bool,
#[arg(short = 'Z', help_heading = "Default Format Options")]
pub format_timestamps_utc: bool,
#[arg(long = "force-color", help_heading = "Display Options")]
pub force_color: bool,
#[arg(long = "no-color", help_heading = "Display Options")]
pub no_color: bool,
#[arg(
long = "mark-gaps",
value_name = "DURATION",
help_heading = "Display Options"
)]
pub mark_gaps: Option<String>,
#[arg(long = "no-emoji", help_heading = "Display Options")]
pub no_emoji: bool,
#[arg(long = "parallel", help_heading = "Performance Options")]
pub parallel: bool,
#[arg(
long = "no-parallel",
help_heading = "Performance Options",
overrides_with = "parallel"
)]
pub no_parallel: bool,
#[arg(
long = "threads",
default_value_t = 0,
help_heading = "Performance Options"
)]
pub threads: usize,
#[arg(long = "batch-size", help_heading = "Performance Options")]
pub batch_size: Option<usize>,
#[arg(
long = "batch-timeout",
default_value_t = 200,
help_heading = "Performance Options"
)]
pub batch_timeout: u64,
#[arg(long = "unordered", help_heading = "Performance Options")]
pub no_preserve_order: bool,
#[arg(short = 's', long = "stats", help_heading = "Metrics and Stats")]
pub stats: bool,
#[arg(
long = "no-stats",
help_heading = "Metrics and Stats",
overrides_with = "stats"
)]
pub no_stats: bool,
#[arg(short = 'S', long = "stats-only", help_heading = "Metrics and Stats")]
pub stats_only: bool,
#[arg(short = 'm', long = "metrics", help_heading = "Metrics and Stats")]
pub metrics: bool,
#[arg(
long = "no-metrics",
help_heading = "Metrics and Stats",
overrides_with = "metrics"
)]
pub no_metrics: bool,
#[arg(long = "metrics-file", help_heading = "Metrics and Stats")]
pub metrics_file: Option<String>,
#[arg(short = 'a', long = "alias", help_heading = "Configuration Options")]
pub alias: Vec<String>,
#[arg(long = "config-file", help_heading = "Configuration Options")]
pub config_file: Option<String>,
#[arg(long = "show-config", help_heading = "Configuration Options")]
pub show_config: bool,
#[arg(long = "ignore-config", help_heading = "Configuration Options")]
pub ignore_config: bool,
#[arg(long = "help-rhai", help_heading = "Help Options")]
pub help_rhai: bool,
#[arg(long = "help-functions", help_heading = "Help Options")]
pub help_functions: bool,
#[arg(long = "help-time", help_heading = "Help Options")]
pub help_time: bool,
#[arg(long = "help-multiline", help_heading = "Help Options")]
pub help_multiline: bool,
}
impl Cli {
pub fn resolve_boolean_flags(&mut self) {
if self.no_stats {
self.stats = false;
}
if self.no_parallel {
self.parallel = false;
}
if self.no_metrics {
self.metrics = false;
}
if self.no_strict {
self.strict = false;
}
}
}
impl Cli {
pub fn get_ordered_script_stages(&self, matches: &ArgMatches) -> Result<Vec<ScriptStageType>> {
let mut stages_with_indices = Vec::new();
if let Some(filter_indices) = matches.indices_of("filters") {
let filter_values: Vec<&String> =
matches.get_many::<String>("filters").unwrap().collect();
for (pos, index) in filter_indices.enumerate() {
stages_with_indices
.push((index, ScriptStageType::Filter(filter_values[pos].clone())));
}
}
if let Some(exec_indices) = matches.indices_of("execs") {
let exec_values: Vec<&String> = matches.get_many::<String>("execs").unwrap().collect();
for (pos, index) in exec_indices.enumerate() {
stages_with_indices.push((index, ScriptStageType::Exec(exec_values[pos].clone())));
}
}
if let Some(exec_file_indices) = matches.indices_of("exec_files") {
let exec_file_values: Vec<&String> =
matches.get_many::<String>("exec_files").unwrap().collect();
for (pos, index) in exec_file_indices.enumerate() {
let file_path = &exec_file_values[pos];
let script_content = std::fs::read_to_string(file_path).map_err(|e| {
anyhow::anyhow!("Failed to read exec file '{}': {}", file_path, e)
})?;
stages_with_indices.push((index, ScriptStageType::Exec(script_content)));
}
}
stages_with_indices.sort_by_key(|(index, _)| *index);
Ok(stages_with_indices
.into_iter()
.map(|(_, stage)| stage)
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use std::io::Write;
use tempfile::NamedTempFile;
fn parse_cli(args: &[String]) -> (Cli, ArgMatches) {
let matches = Cli::command()
.try_get_matches_from(args.iter().map(|s| s.as_str()))
.expect("failed to build matches");
let cli = Cli::parse_from(args.to_vec());
(cli, matches)
}
#[test]
fn ordered_script_stages_preserve_cli_sequence() {
let mut exec_file = NamedTempFile::new().expect("temp file");
writeln!(exec_file, "meta.count = meta.count + 1;").expect("write script");
let exec_path = exec_file.path().to_str().unwrap().to_string();
let args = vec![
"kelora".to_string(),
"--filter".to_string(),
"e.status >= 400".to_string(),
"-e".to_string(),
"e.alert = true;".to_string(),
"--filter".to_string(),
"e.status < 500".to_string(),
"-E".to_string(),
exec_path,
];
let (cli, matches) = parse_cli(&args);
let stages = cli
.get_ordered_script_stages(&matches)
.expect("stages should be parsed");
assert_eq!(stages.len(), 4);
assert!(matches!(
&stages[0],
ScriptStageType::Filter(script) if script == "e.status >= 400"
));
assert!(matches!(
&stages[1],
ScriptStageType::Exec(script) if script == "e.alert = true;"
));
assert!(matches!(
&stages[2],
ScriptStageType::Filter(script) if script == "e.status < 500"
));
assert!(
matches!(&stages[3], ScriptStageType::Exec(script) if script.contains("meta.count"))
);
}
#[test]
fn ordered_script_stages_error_when_exec_file_missing() {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let missing_path = std::env::temp_dir().join(format!(
"kelora-missing-{}-{}.rhai",
std::process::id(),
timestamp
));
let _ = std::fs::remove_file(&missing_path);
let missing = missing_path.to_string_lossy().to_string();
let args = vec!["kelora".to_string(), "-E".to_string(), missing.clone()];
let (cli, matches) = parse_cli(&args);
let err = cli
.get_ordered_script_stages(&matches)
.expect_err("should report missing file");
assert!(err
.to_string()
.contains(&format!("Failed to read exec file '{}':", missing)));
}
#[test]
fn ordered_script_stages_empty_when_no_scripts_specified() {
let args = vec!["kelora".to_string()];
let (cli, matches) = parse_cli(&args);
let stages = cli
.get_ordered_script_stages(&matches)
.expect("empty stages should succeed");
assert!(stages.is_empty());
}
}