use std::io::{self, IsTerminal};
use std::path::PathBuf;
use anyhow::Context;
use clap::{Arg, ArgAction, CommandFactory, Parser, Subcommand, ValueEnum};
use freeswitch_log_parser::{
stamp_lower_bound, stamp_upper_bound, LogLevel, MessageKind, UnclassifiedTracking,
};
use crate::files::DEFAULT_MAX_LINE_BYTES;
use crate::output::{ColorMode, EntryPrinter, FilterConfig, FilterParams};
#[derive(Clone, Copy, ValueEnum)]
pub enum ColorWhen {
Auto,
Always,
Never,
}
#[derive(Parser)]
#[command(name = "fslog", version, about = "FreeSWITCH log file query tool")]
pub struct Cli {
#[arg(long, default_value = "/var/log/freeswitch", env = "FSLOG_DIR")]
pub dir: PathBuf,
#[arg(long, default_value = "auto", value_enum)]
pub color: ColorWhen,
#[arg(long, visible_alias = "less")]
pub pager: bool,
#[arg(long, value_name = "BYTES", default_value_t = DEFAULT_MAX_LINE_BYTES)]
pub max_line_bytes: usize,
#[command(subcommand)]
pub command: Command,
}
pub fn cli_command() -> clap::Command {
short_help(Cli::command())
}
fn short_help(cmd: clap::Command) -> clap::Command {
let cmd = cmd.disable_help_flag(true).arg(
Arg::new("help")
.short('h')
.long("help")
.help("Print help")
.action(ArgAction::HelpShort),
);
let names: Vec<String> = cmd
.get_subcommands()
.map(|sub| sub.get_name().to_string())
.collect();
names
.iter()
.fold(cmd, |cmd, name| cmd.mut_subcommand(name, short_help))
}
#[derive(Subcommand)]
pub enum Command {
List,
Search(SearchArgs),
Read(ReadArgs),
Tail(TailArgs),
#[cfg(feature = "tui")]
Monitor(crate::monitor::MonitorArgs),
Completions {
shell: clap_complete::aot::Shell,
},
}
#[derive(clap::Args, Default)]
pub struct FilterArgs {
#[arg(short, long, value_name = "UUID")]
pub uuid: Vec<String>,
#[arg(short, long, value_name = "LEVEL", value_parser = parse_level)]
pub level: Option<LogLevel>,
#[arg(short, long, value_name = "KIND", value_parser = parse_category)]
pub category: Vec<String>,
#[arg(long, value_name = "PATTERN")]
pub fgrep: Option<String>,
#[arg(long, value_name = "PATTERN", value_parser = parse_regex)]
pub grep: Option<regex::Regex>,
#[arg(long, value_name = "NAME")]
pub codec: Vec<String>,
#[arg(long)]
pub match_blocks: bool,
#[arg(long)]
pub blocks: bool,
#[arg(long)]
pub session: bool,
#[arg(long)]
pub stats: bool,
#[arg(long)]
pub unclassified: bool,
#[arg(short = 'n', long)]
pub line_numbers: bool,
}
impl FilterArgs {
pub fn tracking(&self) -> UnclassifiedTracking {
if self.unclassified {
UnclassifiedTracking::CaptureData
} else {
UnclassifiedTracking::CountOnly
}
}
pub fn printer(&self, color: ColorMode) -> EntryPrinter {
EntryPrinter {
color,
show_blocks: self.blocks,
show_session: self.session,
show_line_numbers: self.line_numbers,
}
}
}
#[derive(clap::Args)]
pub struct SearchArgs {
#[arg(long)]
pub from: Option<String>,
#[arg(long)]
pub until: Option<String>,
#[arg(long, value_name = "DATE", conflicts_with_all = ["from", "until"])]
pub on: Option<String>,
#[arg(long, conflicts_with_all = ["from", "until", "on"])]
pub today: bool,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
pub after_context: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
pub before_context: usize,
#[arg(short = 'C', long, value_name = "N")]
pub context: Option<usize>,
#[arg(long)]
pub related: bool,
#[command(flatten)]
pub filter: FilterArgs,
#[arg(value_name = "PATTERN")]
pub pattern: Option<String>,
#[arg(long = "file", value_name = "FILE")]
pub files: Vec<PathBuf>,
}
impl SearchArgs {
pub fn before(&self) -> usize {
self.context.unwrap_or(self.before_context)
}
pub fn after(&self) -> usize {
self.context.unwrap_or(self.after_context)
}
pub fn window(&self) -> (Option<String>, Option<String>) {
if self.today {
let today = jiff::Zoned::now().date().to_string();
return (Some(today.clone()), Some(today));
}
if let Some(on) = &self.on {
return (Some(on.clone()), Some(on.clone()));
}
(self.from.clone(), self.until.clone())
}
}
#[derive(clap::Args)]
pub struct ReadArgs {
#[command(flatten)]
pub filter: FilterArgs,
#[arg(value_name = "FILE")]
pub file: Option<String>,
}
#[derive(clap::Args)]
pub struct TailArgs {
#[command(flatten)]
pub filter: FilterArgs,
#[arg(long, default_value = "50")]
pub lines: usize,
#[arg(value_name = "FILE")]
pub file: Option<String>,
}
pub fn resolve_color(when: ColorWhen) -> ColorMode {
match when {
ColorWhen::Always => ColorMode::Always,
ColorWhen::Never => ColorMode::Never,
ColorWhen::Auto => {
if io::stdout().is_terminal() {
ColorMode::Always
} else {
ColorMode::Never
}
}
}
}
fn level_labels() -> Vec<&'static str> {
LogLevel::ALL
.iter()
.filter(|l| **l != LogLevel::Disable)
.map(LogLevel::as_str)
.collect()
}
fn parse_level(s: &str) -> Result<LogLevel, String> {
match s.to_ascii_lowercase().parse::<LogLevel>() {
Ok(LogLevel::Disable) => Err(format!(
"disable is the switch's \"log nothing\" sentinel, not a severity; \
valid levels are {}",
level_labels().join(", ")
)),
Err(_) => Err(format!(
"{s} is not a log level; valid levels are {}",
level_labels().join(", ")
)),
Ok(level) => Ok(level),
}
}
fn parse_category(s: &str) -> Result<String, String> {
if MessageKind::ALL_LABELS.contains(&s) {
Ok(s.to_string())
} else {
Err(format!(
"valid categories are {}",
MessageKind::ALL_LABELS.join(", ")
))
}
}
fn parse_regex(s: &str) -> Result<regex::Regex, regex::Error> {
regex::Regex::new(s)
}
pub fn build_filter(
filter: &FilterArgs,
from: Option<&str>,
until: Option<&str>,
) -> anyhow::Result<FilterConfig> {
FilterConfig::new(FilterParams {
uuid: filter.uuid.clone(),
uuid_strict: true,
match_blocks: filter.match_blocks,
min_level: filter.level,
category: filter.category.clone(),
fgrep: filter.fgrep.clone(),
grep: filter.grep.clone(),
codec: filter.codec.clone(),
from_ts: from.map(stamp_lower_bound),
until_ts: until.map(stamp_upper_bound),
})
.context("building the entry filter")
}
#[cfg(test)]
mod tests {
use clap::error::ErrorKind;
use super::cli_command;
fn rendered(args: &[&str]) -> String {
let err = cli_command()
.try_get_matches_from(args)
.expect_err("help exits");
assert_eq!(err.kind(), ErrorKind::DisplayHelp, "{args:?}");
err.render().to_string()
}
#[test]
fn long_help_flag_prints_the_short_help() {
cli_command().debug_assert();
let subcommands: Vec<String> = cli_command()
.get_subcommands()
.map(|sub| sub.get_name().to_string())
.collect();
assert!(!subcommands.is_empty());
assert_eq!(rendered(&["fslog", "--help"]), rendered(&["fslog", "-h"]));
for sub in &subcommands {
assert_eq!(
rendered(&["fslog", sub, "--help"]),
rendered(&["fslog", sub, "-h"]),
"{sub}"
);
}
}
#[test]
fn the_help_subcommand_keeps_the_long_help() {
assert_ne!(
rendered(&["fslog", "help", "search"]),
rendered(&["fslog", "search", "-h"])
);
}
}