1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! The command-line surface: clap types, and the colour and filter objects
//! built from them.
use std::io::{self, IsTerminal};
use std::path::PathBuf;
use anyhow::Context;
use clap::{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 {
/// Log directory
#[arg(long, default_value = "/var/log/freeswitch", env = "FSLOG_DIR")]
pub dir: PathBuf,
/// Color output: auto, always, never
#[arg(long, default_value = "auto", value_enum)]
pub color: ColorWhen,
/// Pipe output through a pager (`$FSLOG_PAGER`, default `less -RFX`)
#[arg(long, visible_alias = "less")]
pub pager: bool,
/// Bytes of one physical line to read before dropping its remainder
#[arg(long, value_name = "BYTES", default_value_t = DEFAULT_MAX_LINE_BYTES)]
pub max_line_bytes: usize,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// List log files with dates and sizes
List,
/// Search/filter entries across multiple files
Search(SearchArgs),
/// Parse and display a single log file
Read(ReadArgs),
/// Follow the log file and display new entries in color
Tail(TailArgs),
/// Live TUI dashboard of active calls
#[cfg(feature = "tui")]
Monitor(crate::monitor::MonitorArgs),
/// Generate shell completion script
Completions {
/// Shell to generate completions for
shell: clap_complete::aot::Shell,
},
}
#[derive(clap::Args, Default)]
pub struct FilterArgs {
/// UUID substring filter (case-insensitive, repeatable, OR logic)
///
/// Matches the channel-UUID column only — the session that produced the
/// line, not every line naming it. A peer leg's bridge line, an
/// Other-Leg-Unique-ID field or a system line mentioning the UUID belongs
/// to another session and is not shown; `--related` follows those legs.
#[arg(short, long, value_name = "UUID")]
pub uuid: Vec<String>,
/// Minimum log level
#[arg(short, long, value_name = "LEVEL", value_parser = parse_level)]
pub level: Option<LogLevel>,
/// Message category filter (repeatable, OR logic)
#[arg(short, long, value_name = "KIND", value_parser = parse_category)]
pub category: Vec<String>,
/// Fixed string substring search (case-insensitive)
///
/// Searches the same field --grep does; see its --help for the scope.
#[arg(long, value_name = "PATTERN")]
pub fgrep: Option<String>,
/// Regex pattern search
///
/// Matches the message text only. The parser splits a log line into
/// `uuid | timestamp | [LEVEL] | source.c:line | message`, and this
/// searches the last field — not the channel-UUID column, and not the
/// attached block lines (dialplan regexes, CHANNEL_DATA dumps, SDP) unless
/// --match-blocks is given. That is deliberate: a term found in a dialplan
/// regex is the configuration mentioning it, not the call. Entries a
/// narrower scope kept out are counted and reported on stderr at end of run.
#[arg(long, value_name = "PATTERN", value_parser = parse_regex)]
pub grep: Option<regex::Regex>,
/// Codec name in a negotiation or SDP block (repeatable, OR logic)
#[arg(long, value_name = "NAME")]
pub codec: Vec<String>,
/// Also match --fgrep/--grep/PATTERN inside attached block lines (SDP, CHANNEL_DATA)
///
/// Widens the pattern scope; see --grep's --help for what it is by default.
#[arg(long)]
pub match_blocks: bool,
/// Expand structured blocks inline (CHANNEL_DATA fields/variables, SDP bodies, codec negotiation)
#[arg(long)]
pub blocks: bool,
/// Annotate entries with tracked session state (dialplan context, channel state, channel name)
#[arg(long)]
pub session: bool,
/// Summary only, no per-entry output
#[arg(long)]
pub stats: bool,
/// Report unclassified lines
#[arg(long)]
pub unclassified: bool,
/// Show line numbers in output
#[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 {
/// Start date (progressive tab-complete from filenames)
#[arg(long)]
pub from: Option<String>,
/// End date (progressive tab-complete from filenames)
#[arg(long)]
pub until: Option<String>,
/// A single date, shorthand for --from DATE --until DATE
#[arg(long, value_name = "DATE", conflicts_with_all = ["from", "until"])]
pub on: Option<String>,
/// Today only, in the machine's local timezone
#[arg(long, conflicts_with_all = ["from", "until", "on"])]
pub today: bool,
/// Skip confirmation prompt for large file sets
#[arg(short = 'y', long)]
pub yes: bool,
/// Lines of context to show after each match
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
pub after_context: usize,
/// Lines of context to show before each match
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
pub before_context: usize,
/// Lines of context before and after each match (sets both -A and -B)
#[arg(short = 'C', long, value_name = "N")]
pub context: Option<usize>,
/// Expand to bridged/transferred peer legs of matching sessions
#[arg(long)]
pub related: bool,
#[command(flatten)]
pub filter: FilterArgs,
/// Fixed-string pattern (case-insensitive); shorthand for --fgrep
///
/// Searches the same field --grep does; see its --help for the scope.
#[arg(value_name = "PATTERN")]
pub pattern: Option<String>,
/// Explicit files to scan (overrides --from/--until auto-discovery)
#[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)
}
/// Resolve the date bounds, collapsing the `--on`/`--today` shorthands into
/// the same pair of partial dates `--from`/`--until` supply.
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,
/// Log file to read (default: freeswitch.log in --dir, or stdin if `-`)
#[arg(value_name = "FILE")]
pub file: Option<String>,
}
#[derive(clap::Args)]
pub struct TailArgs {
#[command(flatten)]
pub filter: FilterArgs,
/// Number of recent lines to show initially
#[arg(long, default_value = "50")]
pub lines: usize,
/// Log file to tail (default: freeswitch.log in --dir)
#[arg(value_name = "FILE")]
pub file: Option<String>,
}
/// Paging does not change this: the process keeps the terminal on its own
/// stdout, only the pager's stdin is a pipe.
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")
}