mod complete;
#[cfg(feature = "tui")]
mod config;
mod context;
mod dialstring;
mod files;
#[cfg(feature = "tui")]
mod monitor;
mod output;
mod pager;
mod prescan;
mod related;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use freeswitch_log_parser::{
LogEntry, LogLevel, LogStream, MessageKind, ParseStats, SessionTracker, TrackedChain,
UnclassifiedTracking,
};
use context::{Emitter, FieldCounts, HiddenCounts};
use files::{
discover_log_files, display_name, filter_files_by_date, format_size, lazy_log_reader,
lossy_line_iter, normalize_date_from, normalize_date_until, open_log_reader, open_tail_reader,
resolve_log_path,
};
use output::{ColorMode, EntryPrinter, FilterConfig, FilterParams};
use pager::{is_broken_pipe, PagedWriter};
#[derive(Clone, Copy, ValueEnum)]
enum ColorWhen {
Auto,
Always,
Never,
}
#[derive(Parser)]
#[command(name = "fslog", version, about = "FreeSWITCH log file query tool")]
struct Cli {
#[arg(long, default_value = "/var/log/freeswitch", env = "FSLOG_DIR")]
dir: PathBuf,
#[arg(long, default_value = "auto", value_enum)]
color: ColorWhen,
#[arg(long, visible_alias = "less")]
pager: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
List,
Search(SearchArgs),
Read(ReadArgs),
Tail(TailArgs),
#[cfg(feature = "tui")]
Monitor(monitor::MonitorArgs),
Completions {
shell: clap_complete::aot::Shell,
},
}
#[derive(clap::Args)]
struct FilterArgs {
#[arg(short, long, value_name = "UUID")]
uuid: Vec<String>,
#[arg(short, long, value_name = "LEVEL")]
level: Option<String>,
#[arg(short, long, value_name = "KIND")]
category: Vec<String>,
#[arg(long, value_name = "PATTERN")]
fgrep: Option<String>,
#[arg(long, value_name = "PATTERN")]
grep: Option<String>,
#[arg(long, value_name = "NAME")]
codec: Vec<String>,
#[arg(long)]
match_blocks: bool,
#[arg(long)]
blocks: bool,
#[arg(long)]
session: bool,
#[arg(long)]
stats: bool,
#[arg(long)]
unclassified: bool,
#[arg(short = 'n', long)]
line_numbers: bool,
}
impl FilterArgs {
fn tracking(&self) -> UnclassifiedTracking {
if self.unclassified {
UnclassifiedTracking::CaptureData
} else {
UnclassifiedTracking::CountOnly
}
}
fn printer(&self, color: ColorMode) -> EntryPrinter {
EntryPrinter {
color,
show_blocks: self.blocks,
show_session: self.session,
show_filename: false,
show_line_numbers: self.line_numbers,
}
}
}
fn print_epilogue(printer: &EntryPrinter, fargs: &FilterArgs, run: &RunSummary) -> io::Result<()> {
if fargs.stats || fargs.unclassified {
printer.print_stats(&mut io::stderr(), &run.stats, run.count, run.session_count)?;
}
if fargs.stats {
printer.print_field_stats(&mut io::stderr(), &run.fields, run.matched)?;
}
if fargs.unclassified {
printer.print_unclassified(&mut io::stderr(), &run.stats)?;
}
Ok(())
}
fn pattern_flag(fargs: &FilterArgs, positional: bool) -> &'static str {
match (fargs.fgrep.is_some() || positional, fargs.grep.is_some()) {
(true, true) => "--fgrep/--grep",
(true, false) if positional => "PATTERN",
(true, false) => "--fgrep",
_ => "--grep",
}
}
fn print_hidden(filter: &FilterConfig, flag: &str, related: bool, hidden: &HiddenCounts) {
if hidden.pattern_in_uuid > 0 {
eprintln!(
"note: {} more entries carry this pattern in the channel-UUID column, which {flag} does not search",
hidden.pattern_in_uuid
);
if let Some(uuid) = filter.suggested_uuid() {
eprintln!("hint: rerun with -u {uuid}");
}
}
if hidden.pattern_in_blocks > 0 {
eprintln!(
"note: {} more entries carry this pattern in attached block lines, which {flag} does not search",
hidden.pattern_in_blocks
);
eprintln!("hint: rerun with --match-blocks");
}
if hidden.uuid_in_body > 0 {
let subject = if filter.uuid_needle_count() == 1 {
"this UUID"
} else {
"a filtered UUID"
};
eprintln!(
"note: {} more entries name {subject} in a message or block line, which -u does not search",
hidden.uuid_in_body
);
if !related {
eprintln!("hint: --related expands to the legs those entries name");
}
}
}
#[derive(clap::Args)]
struct SearchArgs {
#[arg(long)]
from: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long, value_name = "DATE", conflicts_with_all = ["from", "until"])]
on: Option<String>,
#[arg(long, conflicts_with_all = ["from", "until", "on"])]
today: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
after_context: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
before_context: usize,
#[arg(short = 'C', long, value_name = "N")]
context: Option<usize>,
#[arg(long)]
related: bool,
#[command(flatten)]
filter: FilterArgs,
#[arg(value_name = "PATTERN")]
pattern: Option<String>,
#[arg(long = "file", value_name = "FILE")]
files: Vec<PathBuf>,
}
impl SearchArgs {
fn before(&self) -> usize {
self.context.unwrap_or(self.before_context)
}
fn after(&self) -> usize {
self.context.unwrap_or(self.after_context)
}
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)]
struct ReadArgs {
#[command(flatten)]
filter: FilterArgs,
#[arg(value_name = "FILE")]
file: Option<String>,
}
#[derive(clap::Args)]
struct TailArgs {
#[command(flatten)]
filter: FilterArgs,
#[arg(long, default_value = "50")]
lines: usize,
#[arg(value_name = "FILE")]
file: Option<String>,
}
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 build_filter(filter: &FilterArgs, from: Option<&str>, until: Option<&str>) -> FilterConfig {
let min_level: Option<LogLevel> = filter.level.as_ref().map(|l| {
l.parse().unwrap_or_else(|_| {
eprintln!("invalid log level: {l}");
eprintln!("valid levels: {}", LogLevel::ALL_LABELS.join(", "));
process::exit(2);
})
});
for cat in &filter.category {
if !MessageKind::ALL_LABELS.contains(&cat.as_str()) {
eprintln!("invalid category: {cat}");
eprintln!("valid categories: {}", MessageKind::ALL_LABELS.join(", "));
process::exit(2);
}
}
let grep = filter.grep.as_ref().map(|pattern| {
regex::Regex::new(pattern).unwrap_or_else(|e| {
eprintln!("invalid regex: {e}");
process::exit(2);
})
});
FilterConfig::new(FilterParams {
uuid: filter.uuid.clone(),
uuid_strict: true,
match_blocks: filter.match_blocks,
min_level,
category: filter.category.clone(),
fgrep: filter.fgrep.clone(),
grep,
codec: filter.codec.clone(),
from_ts: from.map(normalize_date_from),
until_ts: until.map(normalize_date_until),
})
.unwrap_or_else(|e| {
eprintln!("fslog: {e}");
process::exit(2);
})
}
fn wants_pager(cli: &Cli) -> bool {
cli.pager && !matches!(cli.command, Command::Completions { .. } | Command::Tail(_))
}
fn run_with_output(cli: Cli, out: &mut dyn Write) -> io::Result<()> {
let color = resolve_color(cli.color);
match cli.command {
Command::List => cmd_list(&cli.dir, out),
Command::Search(ref args) => cmd_search(&cli.dir, args, color, out),
Command::Read(ref args) => cmd_read(&cli.dir, args, color, out),
Command::Tail(ref args) => cmd_tail(&cli.dir, args, color, out),
#[cfg(feature = "tui")]
Command::Monitor(_) => unreachable!("handled in main()"),
Command::Completions { shell } => {
let mut cmd = Cli::command();
complete::generate_completions(shell, &mut cmd);
Ok(())
}
}
}
fn cmd_list(dir: &Path, out: &mut dyn Write) -> io::Result<()> {
let files = discover_log_files(dir)?;
for f in &files {
let date = f
.date
.as_deref()
.map(|d| {
if d.len() >= 16 {
format!("{} {}:{}", &d[..10], &d[11..13], &d[14..16])
} else {
d.to_string()
}
})
.unwrap_or_else(|| "(current)".to_string());
let size = format_size(f.size);
let name = f.path.file_name().unwrap().to_string_lossy();
writeln!(out, "{date:<17} {size:>6} {name}")?;
}
Ok(())
}
pub(crate) fn separator_entry(kind: MessageKind, msg: String) -> LogEntry {
LogEntry {
message_kind: kind,
..LogEntry::synthetic(msg)
}
}
const MAX_UNCONFIRMED_FILES: usize = 20;
const MAX_UNCONFIRMED_BYTES: u64 = 1024 * 1024 * 1024;
fn max_unconfirmed_bytes() -> u64 {
let raw = match std::env::var("FSLOG_CONFIRM_SIZE") {
Ok(raw) => raw,
Err(std::env::VarError::NotPresent) => return MAX_UNCONFIRMED_BYTES,
Err(e) => {
eprintln!("fslog: FSLOG_CONFIRM_SIZE is set but unusable: {e}");
process::exit(2);
}
};
raw.parse().unwrap_or_else(|e| {
eprintln!("fslog: FSLOG_CONFIRM_SIZE={raw} is not a byte count: {e}");
process::exit(2);
})
}
fn coverage_note(files: &[files::LogFile]) -> Option<String> {
let stamps: Vec<&str> = files.iter().filter_map(|f| f.date.as_deref()).collect();
let active = files.iter().any(|f| f.date.is_none());
match (stamps.first(), active) {
(None, true) => Some("log coverage here: active log only".to_string()),
(None, false) => None,
(Some(first), _) => {
let day = |s: &str| s[..s.len().min(10)].to_string();
let last = if active {
"now".to_string()
} else {
day(stamps.last()?)
};
Some(format!("log coverage here: {} – {last}", day(first)))
}
}
}
fn resolve_search_files(
dir: &Path,
args: &SearchArgs,
from: Option<&str>,
until: Option<&str>,
) -> io::Result<Option<Vec<(String, PathBuf)>>> {
if !args.files.is_empty() {
let v = args
.files
.iter()
.map(|p| (display_name(p), p.clone()))
.collect();
return Ok(Some(v));
}
let all_files = discover_log_files(dir)?;
let selected = filter_files_by_date(&all_files, from, until);
if selected.is_empty() {
eprintln!("no log files match the date range");
return Ok(None);
}
let total_size: u64 = selected.iter().map(|f| f.size).sum();
if !args.yes && (selected.len() > MAX_UNCONFIRMED_FILES || total_size > max_unconfirmed_bytes())
{
let scale = format!("{} files ({})", selected.len(), format_size(total_size));
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
"refusing to scan {scale} without confirmation; pass -y to override"
)));
}
eprint!("about to scan {scale}, proceed? [y/N] ");
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
return Ok(None);
}
}
let v = selected
.iter()
.map(|f| (display_name(&f.path), f.path.clone()))
.collect();
Ok(Some(v))
}
fn build_segments(files: &[(String, PathBuf)]) -> Vec<(String, Box<dyn Iterator<Item = String>>)> {
files
.iter()
.map(|(name, path)| (name.clone(), lazy_log_reader(path.clone())))
.collect()
}
fn cmd_search(
dir: &Path,
args: &SearchArgs,
color: ColorMode,
out: &mut dyn Write,
) -> io::Result<()> {
if args.pattern.is_some() && args.filter.fgrep.is_some() {
return Err(io::Error::other(
"provide either a positional PATTERN or --fgrep, not both",
));
}
let (from, until) = args.window();
let mut filter = build_filter(&args.filter, from.as_deref(), until.as_deref());
if let Some(p) = &args.pattern {
filter.set_fgrep(p)?;
}
let files = match resolve_search_files(dir, args, from.as_deref(), until.as_deref())? {
Some(f) => f,
None => return Ok(()),
};
let report_empty = || -> io::Result<()> {
let note = if args.files.is_empty() {
coverage_note(&discover_log_files(dir)?)
} else {
None
};
match note {
Some(n) => eprintln!("no matching entries; {n}"),
None => eprintln!("no matching entries"),
}
Ok(())
};
let seeded = if files.len() > 1 {
match args
.pattern
.as_deref()
.or(args.filter.fgrep.as_deref())
.or(match args.filter.uuid.as_slice() {
[only] => Some(only.as_str()),
_ => None,
})
.filter(|n| prescan::is_single_line_safe(n))
{
Some(needle) => prescan::narrow(&files, needle),
None => files.clone(),
}
} else {
files.clone()
};
if seeded.is_empty() {
return report_empty();
}
let printer = args.filter.printer(color);
let mut rendered = seeded;
if args.related {
let discovered = related::discover(build_segments(&rendered), &filter.for_discovery());
if discovered.is_empty() {
return report_empty();
}
let seeds: Vec<String> = discovered.into_iter().collect();
filter.set_uuids(&seeds)?;
filter.uuid_strict = true;
rendered = files;
}
let run = run_output(
out,
build_segments(&rendered),
&filter,
&printer,
&args.filter,
args.before(),
args.after(),
)?;
if run.matched == 0 {
report_empty()?;
}
print_hidden(
&filter,
pattern_flag(&args.filter, args.pattern.is_some()),
args.related,
&run.hidden,
);
print_epilogue(&printer, &args.filter, &run)
}
struct RunSummary {
stats: ParseStats,
session_count: usize,
count: u64,
matched: u64,
hidden: HiddenCounts,
fields: FieldCounts,
}
fn run_output(
out: &mut dyn Write,
segments: Vec<(String, Box<dyn Iterator<Item = String>>)>,
filter: &FilterConfig,
printer: &EntryPrinter,
fargs: &FilterArgs,
before: usize,
after: usize,
) -> io::Result<RunSummary> {
let (chain, seg_tracker) = TrackedChain::new(segments);
let stream = LogStream::new(chain).unclassified_tracking(fargs.tracking());
let mut emitter = Emitter::new(printer, filter, &seg_tracker, fargs.stats, before, after);
let (stats, session_count) = if fargs.session {
let mut tracker = SessionTracker::new(stream);
for enriched in tracker.by_ref() {
match emitter.on_entry(out, &enriched.entry, enriched.session.as_ref()) {
Err(e) if is_broken_pipe(&e) => break,
other => other?,
}
}
(tracker.stats().clone(), tracker.sessions().len())
} else {
let mut stream = stream;
for entry in stream.by_ref() {
match emitter.on_entry(out, &entry, None) {
Err(e) if is_broken_pipe(&e) => break,
other => other?,
}
}
(stream.stats().clone(), 0)
};
Ok(RunSummary {
stats,
session_count,
count: emitter.count,
matched: emitter.matched,
hidden: emitter.hidden,
fields: emitter.fields,
})
}
fn cmd_read(dir: &Path, args: &ReadArgs, color: ColorMode, out: &mut dyn Write) -> io::Result<()> {
let filter = build_filter(&args.filter, None, None);
let (name, lines): (String, Box<dyn Iterator<Item = String>>) = match args.file.as_deref() {
Some("-") => (
"-".to_string(),
lossy_line_iter(Box::new(io::stdin().lock())),
),
Some(path) => {
let p = PathBuf::from(path);
let p = if p.is_absolute() || p.exists() {
p
} else {
dir.join(&p)
};
(display_name(&p), open_log_reader(&p)?)
}
None => {
let p = resolve_log_path(dir, None);
(display_name(&p), open_log_reader(&p)?)
}
};
let printer = args.filter.printer(color);
let run = run_output(
out,
vec![(name, lines)],
&filter,
&printer,
&args.filter,
0,
0,
)?;
print_hidden(
&filter,
pattern_flag(&args.filter, false),
false,
&run.hidden,
);
print_epilogue(&printer, &args.filter, &run)
}
fn cmd_tail(dir: &Path, args: &TailArgs, color: ColorMode, out: &mut dyn Write) -> io::Result<()> {
let filter = build_filter(&args.filter, None, None);
let path = resolve_log_path(dir, args.file.as_deref());
let lines = open_tail_reader(&path, args.lines)?;
let printer = args.filter.printer(color);
let stream = LogStream::new(lines).unclassified_tracking(args.filter.tracking());
let mut tracker = SessionTracker::new(stream);
for enriched in tracker.by_ref() {
if !filter.matches(&enriched.entry) {
continue;
}
if !args.filter.stats {
let written = printer
.print_entry(out, &enriched.entry, enriched.session.as_ref(), None)
.and_then(|()| out.flush());
match written {
Err(e) if is_broken_pipe(&e) => break,
other => other?,
}
}
}
Ok(())
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let cli = Cli::parse();
#[cfg(feature = "tui")]
if let Command::Monitor(args) = cli.command {
if let Err(e) = monitor::run(&cli.dir, args) {
eprintln!("fslog: {e}");
process::exit(1);
}
return;
}
let mut out = PagedWriter::new(wants_pager(&cli));
let result = run_with_output(cli, &mut out);
let finished = out.finish();
if let Err(e) = result.and(finished) {
eprintln!("fslog: {e}");
process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn log_file(date: Option<&str>) -> files::LogFile {
files::LogFile {
path: PathBuf::from("freeswitch.log"),
date: date.map(str::to_string),
size: 0,
}
}
#[test]
fn coverage_spans_first_stamp_to_last() {
let files = vec![
log_file(Some("2026-05-01-00-00-00")),
log_file(Some("2026-05-20-00-00-00")),
];
assert_eq!(
coverage_note(&files).unwrap(),
"log coverage here: 2026-05-01 – 2026-05-20"
);
}
#[test]
fn active_log_makes_the_upper_bound_now() {
let files = vec![log_file(Some("2026-05-01-00-00-00")), log_file(None)];
assert_eq!(
coverage_note(&files).unwrap(),
"log coverage here: 2026-05-01 – now"
);
}
#[test]
fn active_log_alone_has_no_span() {
assert_eq!(
coverage_note(&[log_file(None)]).unwrap(),
"log coverage here: active log only"
);
}
#[test]
fn no_files_no_note() {
assert!(coverage_note(&[]).is_none());
}
}