use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher;
use std::fmt::Write as _;
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
use freeswitch_log_parser::{
find_uuids, normalize_entry_timestamp, truncate_at_char_boundary, Block, LogLevel, MessageKind,
};
use crate::dialstring::{dial_string_of, print_dial_string};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Always,
Never,
}
const RESET: &str = "\x1b[0m";
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const MAGENTA: &str = "\x1b[35m";
const CYAN: &str = "\x1b[36m";
const DIM: &str = "\x1b[2m";
const DIM_YELLOW: &str = "\x1b[33;2m";
const DIM_GREEN: &str = "\x1b[32;2m";
const BRIGHT_GREEN: &str = "\x1b[92m";
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = l - c / 2.0;
let (r, g, b) = match h as u32 {
0..=59 => (c, x, 0.0),
60..=119 => (x, c, 0.0),
120..=179 => (0.0, c, x),
180..=239 => (0.0, x, c),
240..=299 => (x, 0.0, c),
_ => (c, 0.0, x),
};
(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
fn uuid_truecolor(uuid: &str) -> (u8, u8, u8) {
let mut hasher = DefaultHasher::new();
uuid.hash(&mut hasher);
let hue = (hasher.finish() % 360) as f64;
hsl_to_rgb(hue, 0.30, 0.82)
}
fn split_dialplan_line(msg: &str) -> Option<(&str, &str)> {
let tag = ["Dialplan: ", "Chatplan: "]
.into_iter()
.find(|t| msg.starts_with(t))?;
let (_channel, data) = msg[tag.len()..].split_once(' ')?;
Some((&msg[..msg.len() - data.len() - 1], data))
}
fn strip_repeated_prefix<'a>(line: &'a str, uuid: &str) -> &'a str {
let rest = line
.strip_prefix(uuid)
.and_then(|r| r.strip_prefix(' '))
.unwrap_or(line);
split_dialplan_line(rest).map_or(rest, |(_, data)| data)
}
fn write_uuid(out: &mut String, uuid: &str) {
let (r, g, b) = uuid_truecolor(uuid);
write!(out, "\x1b[38;2;{r};{g};{b}m{uuid}{RESET}").expect("writing to a String cannot fail");
}
fn colorize_uuids<'a>(text: &'a str, resume: &str) -> Cow<'a, str> {
let mut hits = find_uuids(text).peekable();
if hits.peek().is_none() {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len() + 64);
let mut last = 0;
for (start, uuid) in hits {
out.push_str(&text[last..start]);
write_uuid(&mut out, uuid);
out.push_str(resume);
last = start + uuid.len();
}
out.push_str(&text[last..]);
Cow::Owned(out)
}
fn colorize_pass_fail<'a>(text: &'a str, resume: &str) -> Cow<'a, str> {
if !text.contains("(PASS)") && !text.contains("(FAIL)") {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len() + 32);
let mut rest = text;
while let Some((idx, verdict, color)) = rest
.find("(PASS)")
.map(|i| (i, "(PASS)", BRIGHT_GREEN))
.into_iter()
.chain(rest.find("(FAIL)").map(|i| (i, "(FAIL)", RED)))
.min_by_key(|(i, _, _)| *i)
{
out.push_str(&rest[..idx]);
out.push_str(color);
out.push_str(verdict);
out.push_str(RESET);
out.push_str(resume);
rest = &rest[idx + verdict.len()..];
}
out.push_str(rest);
Cow::Owned(out)
}
fn level_color(level: Option<LogLevel>) -> &'static str {
match level {
Some(LogLevel::Err | LogLevel::Crit | LogLevel::Alert) => RED,
Some(LogLevel::Warning) => MAGENTA,
Some(LogLevel::Info) => GREEN,
Some(LogLevel::Notice) => CYAN,
Some(LogLevel::Debug) => YELLOW,
Some(LogLevel::Console) => GREEN,
None => "",
}
}
pub struct EntryPrinter {
pub color: ColorMode,
pub show_blocks: bool,
pub show_session: bool,
pub show_filename: bool,
pub show_line_numbers: bool,
}
impl EntryPrinter {
pub fn print_entry(
&self,
w: &mut dyn Write,
entry: &freeswitch_log_parser::LogEntry,
session: Option<&freeswitch_log_parser::SessionSnapshot>,
filename: Option<&str>,
) -> io::Result<()> {
let level = entry
.level
.map(|l| l.to_string())
.unwrap_or_else(|| "-".to_string());
let time = if entry.timestamp.len() >= 11 {
&entry.timestamp[11..]
} else {
&entry.timestamp
};
let use_color = self.color == ColorMode::Always;
let lc = if use_color {
level_color(entry.level)
} else {
""
};
let reset = if use_color { RESET } else { "" };
let dim = if use_color { DIM } else { "" };
if matches!(
entry.message_kind,
MessageKind::FileChange | MessageKind::DateChange
) {
return writeln!(w, "{dim}── {}{reset}", entry.message);
}
let uuid = if entry.uuid.is_empty() {
format!("{dim}-{reset}")
} else if use_color {
let mut s = String::new();
write_uuid(&mut s, &entry.uuid);
s
} else {
entry.uuid.clone()
};
let inline = !entry.attached.is_empty()
&& ((self.show_blocks && entry.block.is_none()) || entry.attached.len() == 1);
let (head, head_data) = match inline
.then(|| split_dialplan_line(&entry.message))
.flatten()
{
Some((channel, data)) => (channel, Some(data)),
None => (entry.message.as_str(), None),
};
let msg = if use_color {
colorize_uuids(head, lc)
} else {
Cow::Borrowed(head)
};
if let Some(fname) = filename.filter(|_| self.show_filename) {
write!(w, "{dim}{fname}{reset} ")?;
}
if self.show_line_numbers {
write!(w, "{lc}L{line:>6} ", line = entry.line_number)?;
}
writeln!(
w,
"{lc}{time:>15} {level:>7}{reset} {uuid} {lc}[{mkind}]{reset} {lc}{msg}{reset}",
mkind = entry.message_kind,
)?;
if self.show_blocks {
if let Some(block) = &entry.block {
self.print_block(w, block, use_color)?;
}
if let Some(args) = dial_string_of(&entry.message_kind) {
let (lbl, val) = if use_color { (CYAN, DIM) } else { ("", "") };
print_dial_string(w, args, lbl, val, reset)?;
}
}
if self.show_session {
if let Some(session) = session {
self.print_session(w, session, use_color)?;
}
}
for warning in &entry.warnings {
let wc = if use_color { MAGENTA } else { "" };
writeln!(w, "{wc} WARN {warning}{reset}")?;
}
if !entry.attached.is_empty() {
let dim_s = if use_color { DIM } else { "" };
if inline {
for line in head_data.into_iter().chain(&entry.attached) {
let line = strip_repeated_prefix(line, &entry.uuid);
let rendered = if use_color {
match colorize_uuids(line, dim_s) {
Cow::Borrowed(s) => colorize_pass_fail(s, dim_s),
Cow::Owned(s) => match colorize_pass_fail(&s, dim_s) {
Cow::Borrowed(_) => Cow::Owned(s),
Cow::Owned(both) => Cow::Owned(both),
},
}
} else {
Cow::Borrowed(line)
};
writeln!(w, "{dim_s} {rendered}{reset}")?;
}
} else if !(self.show_blocks && entry.block.is_some()) {
writeln!(
w,
"{dim_s} ({} attached lines){reset}",
entry.attached.len()
)?;
}
}
Ok(())
}
#[cfg(feature = "sdp")]
fn sdp_summary_line(block: &Block) -> Option<String> {
use freeswitch_types::sdp::SdpCodecEntry;
let codecs = block.sdp_codecs()?.ok()?;
let mut parts: Vec<String> = codecs
.iter()
.map(|e| match e {
SdpCodecEntry::Rtp(c) => {
let mut s = format!("{}/{}", c.name(), c.clock_rate());
if let Some(ch) = c.channels() {
if ch > 1 {
s.push_str(&format!("/{ch}"));
}
}
s
}
_ => "T.38".to_string(),
})
.collect();
for rate in codecs.telephone_event_rates() {
parts.push(format!("telephone-event/{rate}"));
}
if codecs.has_comfort_noise() {
parts.push("CN".to_string());
}
for u in codecs.unmapped() {
parts.push(format!("pt{}?", u.payload_type));
}
if parts.is_empty() {
return None;
}
Some(parts.join(", "))
}
fn print_block(&self, w: &mut dyn Write, block: &Block, use_color: bool) -> io::Result<()> {
let bc = if use_color { DIM_GREEN } else { "" };
let sc = if use_color { BRIGHT_GREEN } else { "" };
let reset = if use_color { RESET } else { "" };
match block {
Block::ChannelData { fields, variables } => {
for (name, value) in fields {
writeln!(w, "{bc} field {name}: {value}{reset}")?;
}
for (name, value) in variables {
writeln!(w, "{bc} var {name}: {value}{reset}")?;
}
}
Block::Sdp { direction, body } => {
writeln!(
w,
"{sc} sdp {direction} ({} lines){reset}",
body.len()
)?;
#[cfg(feature = "sdp")]
if let Some(summary) = Self::sdp_summary_line(block) {
writeln!(w, "{sc} sdp {summary}{reset}")?;
}
for line in body {
writeln!(w, "{sc} sdp {line}{reset}")?;
}
}
Block::CodecNegotiation {
media,
comparisons,
matched,
near_matched,
} => {
let cc = if use_color { DIM_YELLOW } else { "" };
writeln!(
w,
"{cc} codec {media}: {} comparisons, {} matched, {} near{reset}",
comparisons.len(),
matched.len(),
near_matched.len(),
)?;
for (offered, local) in comparisons {
writeln!(w, "{cc} codec {offered} vs {local}{reset}")?;
}
for c in matched {
writeln!(w, "{cc} codec MATCH {c}{reset}")?;
}
for c in near_matched {
writeln!(w, "{cc} codec NEAR {c}{reset}")?;
}
}
_ => {
writeln!(w, "{bc} block {block:?}{reset}")?;
}
}
Ok(())
}
fn print_session(
&self,
w: &mut dyn Write,
session: &freeswitch_log_parser::SessionSnapshot,
use_color: bool,
) -> io::Result<()> {
let dim = if use_color { DIM } else { "" };
let reset = if use_color { RESET } else { "" };
let mut parts = Vec::new();
if let Some(ctx) = &session.dialplan_context {
parts.push(format!("ctx={ctx}"));
}
if let Some(d) = &session.initial_destination {
parts.push(format!("dest={d}"));
}
if let Some(state) = &session.channel_state {
parts.push(format!("state={state}"));
}
if let Some(name) = &session.channel_name {
parts.push(format!("ch={name}"));
}
if let Some(conf) = &session.conference {
parts.push(format!("conf={}", conf.name));
if let Some(id) = conf.member_id {
parts.push(format!("member={id}"));
}
}
if !parts.is_empty() {
writeln!(w, "{dim} session {}{reset}", parts.join(" "))?;
}
Ok(())
}
pub fn print_stats(
&self,
w: &mut dyn Write,
stats: &freeswitch_log_parser::ParseStats,
entry_count: u64,
session_count: usize,
) -> io::Result<()> {
writeln!(
w,
"{entry_count} entries, {} lines, {} unclassified, {session_count} sessions",
stats.lines_processed, stats.lines_unclassified,
)
}
pub fn print_unclassified(
&self,
w: &mut dyn Write,
stats: &freeswitch_log_parser::ParseStats,
) -> io::Result<()> {
if stats.unclassified_lines.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, "unclassified lines:")?;
for u in &stats.unclassified_lines {
writeln!(
w,
" L{}: {:?}{}",
u.line_number,
u.reason,
u.data
.as_ref()
.map(|d| format!(" | {}", truncate_at_char_boundary(d, 100)))
.unwrap_or_default(),
)?;
}
Ok(())
}
}
fn build_matcher(needles: &[String]) -> io::Result<Option<AhoCorasick>> {
if needles.is_empty() {
return Ok(None);
}
AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.build(needles)
.map(Some)
.map_err(|e| {
io::Error::other(format!(
"cannot build a matcher for {} pattern(s): {e}",
needles.len()
))
})
}
#[derive(Default)]
pub struct FilterParams {
pub uuid: Vec<String>,
pub uuid_strict: bool,
pub match_blocks: bool,
pub min_level: Option<LogLevel>,
pub category: Vec<String>,
pub fgrep: Option<String>,
pub grep: Option<regex::Regex>,
pub codec: Vec<String>,
pub from_ts: Option<String>,
pub until_ts: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hidden {
PatternInUuid,
PatternInBlocks,
UuidInBody,
}
pub enum Verdict {
Match,
Hidden(Hidden),
Reject,
}
#[derive(Clone, Default)]
pub struct FilterConfig {
uuid_ac: Option<AhoCorasick>,
uuid_needles: Vec<String>,
pub uuid_strict: bool,
pub match_blocks: bool,
pub min_level: Option<LogLevel>,
pub category: Vec<String>,
fgrep_ac: Option<AhoCorasick>,
fgrep_needle: Option<String>,
pub grep: Option<regex::Regex>,
grep_ci: Option<regex::Regex>,
pub codec: Vec<String>,
pub from_ts: Option<String>,
pub until_ts: Option<String>,
}
fn case_insensitive(re: ®ex::Regex) -> io::Result<regex::Regex> {
regex::RegexBuilder::new(re.as_str())
.case_insensitive(true)
.build()
.map_err(|e| io::Error::other(format!("cannot build a case-insensitive probe: {e}")))
}
impl FilterConfig {
pub fn new(p: FilterParams) -> io::Result<Self> {
Ok(FilterConfig {
uuid_ac: build_matcher(&p.uuid)?,
uuid_needles: p.uuid,
uuid_strict: p.uuid_strict,
match_blocks: p.match_blocks,
min_level: p.min_level,
category: p.category,
fgrep_ac: build_matcher(p.fgrep.as_slice())?,
fgrep_needle: p.fgrep,
grep_ci: p.grep.as_ref().map(case_insensitive).transpose()?,
grep: p.grep,
codec: p.codec.iter().map(|c| c.to_lowercase()).collect(),
from_ts: p.from_ts,
until_ts: p.until_ts,
})
}
pub fn set_uuids(&mut self, needles: &[String]) -> io::Result<()> {
self.uuid_ac = build_matcher(needles)?;
self.uuid_needles = needles.to_vec();
Ok(())
}
pub fn set_fgrep(&mut self, needle: &str) -> io::Result<()> {
self.fgrep_ac = build_matcher(std::slice::from_ref(&needle.to_string()))?;
self.fgrep_needle = Some(needle.to_string());
Ok(())
}
pub fn uuid_needle_count(&self) -> usize {
self.uuid_needles.len()
}
pub fn suggested_uuid(&self) -> Option<&str> {
let sources = self
.fgrep_needle
.as_deref()
.into_iter()
.chain(self.grep.as_ref().map(|re| re.as_str()));
let uuid = sources.flat_map(find_uuids).map(|(_, u)| u).next()?;
let known = self
.uuid_needles
.iter()
.any(|n| n.eq_ignore_ascii_case(uuid));
(!known).then_some(uuid)
}
pub fn for_discovery(&self) -> FilterConfig {
FilterConfig {
uuid_strict: false,
match_blocks: true,
category: Vec::new(),
..self.clone()
}
}
fn codec_matches(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
let wanted = |name: &str| {
let name = name.to_lowercase();
self.codec.iter().any(|c| name.contains(c.as_str()))
};
match &entry.block {
Some(Block::CodecNegotiation {
comparisons,
matched,
near_matched,
..
}) => {
comparisons
.iter()
.any(|(o, l)| wanted(&o.name) || wanted(&l.name))
|| matched.iter().chain(near_matched).any(|c| wanted(&c.name))
}
#[cfg(feature = "sdp")]
Some(block @ Block::Sdp { .. }) => match block.sdp_codecs() {
Some(Ok(codecs)) => codecs.iter().any(|e| match e {
freeswitch_types::sdp::SdpCodecEntry::Rtp(c) => wanted(c.name()),
_ => false,
}),
_ => false,
},
_ => false,
}
}
fn level_ok(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
match (self.min_level, entry.level) {
(Some(min), Some(level)) => level >= min,
_ => true,
}
}
fn category_ok(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
self.category.is_empty()
|| self
.category
.iter()
.any(|c| entry.message_kind.label() == c.as_str())
}
fn codec_ok(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
self.codec.is_empty() || self.codec_matches(entry)
}
fn window_ok(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
if (self.from_ts.is_none() && self.until_ts.is_none()) || entry.timestamp.is_empty() {
return true;
}
let entry_ts = normalize_entry_timestamp(&entry.timestamp);
if let Some(ref from) = self.from_ts {
if entry_ts.as_str() < from.as_str() {
return false;
}
}
if let Some(ref until) = self.until_ts {
if entry_ts.as_str() > until.as_str() {
return false;
}
}
true
}
fn uuid_ok_scoped(&self, entry: &freeswitch_log_parser::LogEntry, body: bool) -> bool {
match self.uuid_ac {
None => true,
Some(ref ac) => {
ac.is_match(entry.uuid.as_bytes())
|| (body
&& (ac.is_match(entry.message.as_bytes())
|| entry.attached.iter().any(|l| ac.is_match(l.as_bytes()))))
}
}
}
fn pattern_ok_scoped(&self, entry: &freeswitch_log_parser::LogEntry, blocks: bool) -> bool {
if let Some(ref ac) = self.fgrep_ac {
let hit = ac.is_match(entry.message.as_bytes())
|| (blocks && entry.attached.iter().any(|l| ac.is_match(l.as_bytes())));
if !hit {
return false;
}
}
if let Some(ref re) = self.grep {
let hit = re.is_match(&entry.message)
|| (blocks && entry.attached.iter().any(|l| re.is_match(l)));
if !hit {
return false;
}
}
true
}
fn others_ok(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
self.level_ok(entry)
&& self.category_ok(entry)
&& self.codec_ok(entry)
&& self.window_ok(entry)
}
fn pattern_in_uuid_column(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
if let Some(ref ac) = self.fgrep_ac {
if !ac.is_match(entry.uuid.as_bytes()) {
return false;
}
}
match self.grep_ci {
Some(ref re) => re.is_match(&entry.uuid),
None => true,
}
}
pub fn matches(&self, entry: &freeswitch_log_parser::LogEntry) -> bool {
self.others_ok(entry)
&& self.uuid_ok_scoped(entry, !self.uuid_strict)
&& self.pattern_ok_scoped(entry, self.match_blocks)
}
pub fn verdict(&self, entry: &freeswitch_log_parser::LogEntry) -> Verdict {
if !self.others_ok(entry) {
return Verdict::Reject;
}
let uuid_ok = self.uuid_ok_scoped(entry, !self.uuid_strict);
let pattern_ok = self.pattern_ok_scoped(entry, self.match_blocks);
match (uuid_ok, pattern_ok) {
(true, true) => Verdict::Match,
(true, false) => {
if self.pattern_in_uuid_column(entry) {
Verdict::Hidden(Hidden::PatternInUuid)
} else if !self.match_blocks && self.pattern_ok_scoped(entry, true) {
Verdict::Hidden(Hidden::PatternInBlocks)
} else {
Verdict::Reject
}
}
(false, true) => {
if self.uuid_strict && self.uuid_ok_scoped(entry, true) {
Verdict::Hidden(Hidden::UuidInBody)
} else {
Verdict::Reject
}
}
(false, false) => Verdict::Reject,
}
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use freeswitch_log_parser::{AttachedLines, LineKind, LogEntry, MessageKind};
fn entry(uuid: &str, message: &str, attached: &[&str]) -> LogEntry {
let mut a = AttachedLines::new();
for l in attached {
a.push(l);
}
LogEntry {
uuid: uuid.to_string(),
timestamp: String::new(),
level: None,
idle_pct: None,
source: None,
message: message.to_string(),
kind: LineKind::Full,
message_kind: MessageKind::General,
block: None,
attached: a,
line_number: 0,
warnings: Vec::new(),
}
}
const PEER: &str = "11111111-2222-3333-4444-555555555555";
fn printer(color: ColorMode, show_blocks: bool) -> EntryPrinter {
EntryPrinter {
color,
show_blocks,
show_session: false,
show_filename: false,
show_line_numbers: false,
}
}
fn render(printer: &EntryPrinter, entry: &LogEntry) -> String {
let mut out: Vec<u8> = Vec::new();
printer.print_entry(&mut out, entry, None, None).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn attached_lines_inline_under_blocks() {
let e = entry(
"u",
"Dialplan: parsing",
&["Regex (PASS) x =~ /y/", "Action set"],
);
let out = render(&printer(ColorMode::Never, true), &e);
assert!(out.contains("Regex (PASS) x =~ /y/"), "{out}");
assert!(out.contains("Action set"), "{out}");
assert!(!out.contains("attached lines"), "{out}");
}
const CHAN: &str = "sofia/internal/1262@pbx.example.test:5062";
#[test]
fn the_channel_heads_the_block_and_every_body_line_is_data() {
let uuid = "9865d278-537b-4d4a-af91-f836729f78f2";
let e = entry(
uuid,
&format!("Dialplan: {CHAN} parsing [default->unloop] continue=false"),
&[format!("{uuid} Dialplan: {CHAN} Regex (PASS) [unloop] break=on-false").as_str()],
);
let out = render(&printer(ColorMode::Never, true), &e);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].ends_with(&format!("Dialplan: {CHAN}")), "{out}");
assert_eq!(lines[1].trim(), "parsing [default->unloop] continue=false");
assert_eq!(lines[2].trim(), "Regex (PASS) [unloop] break=on-false");
}
#[test]
fn a_dialplan_line_with_no_body_keeps_its_data() {
let e = entry(
"u",
&format!("Dialplan: {CHAN} Absolute Condition [global]"),
&[],
);
let out = render(&printer(ColorMode::Never, true), &e);
assert!(
out.trim_end().ends_with("Absolute Condition [global]"),
"{out}"
);
}
#[test]
fn a_foreign_uuid_in_a_body_line_survives() {
let e = entry(
"u",
&format!("Dialplan: {CHAN} parsing [a->b] continue=true"),
&[format!("u Dialplan: {CHAN} Regex (FAIL) ${{hdr}}({PEER}) =~ /^$/").as_str()],
);
let out = render(&printer(ColorMode::Never, true), &e);
assert!(out.contains(PEER), "{out}");
assert!(!out.contains(&format!("Dialplan: {CHAN} Regex")), "{out}");
}
#[test]
fn a_non_dialplan_continuation_only_loses_its_uuid() {
let e = entry(
"u",
"msg",
&["u EXECUTE [depth=0] sofia/internal/1001 bridge(sofia/gateway/gw/5551234)"],
);
let out = render(&printer(ColorMode::Never, false), &e);
assert!(
out.contains(
" EXECUTE [depth=0] sofia/internal/1001 bridge(sofia/gateway/gw/5551234)"
),
"{out}"
);
}
#[test]
fn attached_lines_collapse_without_blocks() {
let e = entry("u", "Dialplan: parsing", &["one", "two"]);
let out = render(&printer(ColorMode::Never, false), &e);
assert!(out.contains("(2 attached lines)"), "{out}");
}
#[test]
fn lone_attached_line_always_inline() {
let e = entry("u", "msg", &["the only continuation"]);
let out = render(&printer(ColorMode::Never, false), &e);
assert!(out.contains("the only continuation"), "{out}");
}
fn channel_data_entry() -> LogEntry {
let mut e = entry(
"u",
"CHANNEL_DATA:",
&["Channel-Name: [x]", "variable_a: [b]"],
);
e.block = Some(Block::ChannelData {
fields: vec![("Channel-Name".into(), "x".into())],
variables: vec![("variable_a".into(), "b".into())],
});
e
}
#[test]
fn an_expanded_block_does_not_also_count_its_raw_lines() {
let out = render(&printer(ColorMode::Never, true), &channel_data_entry());
assert!(out.contains("field Channel-Name: x"), "{out}");
assert!(!out.contains("attached lines"), "{out}");
}
#[test]
fn without_blocks_the_count_is_the_only_signal() {
let out = render(&printer(ColorMode::Never, false), &channel_data_entry());
assert!(out.contains("(2 attached lines)"), "{out}");
}
#[test]
fn a_marker_prints_as_a_rule_not_empty_columns() {
let mut e = entry("", "freeswitch.log.1.xz", &[]);
e.message_kind = MessageKind::FileChange;
let out = render(&printer(ColorMode::Never, true), &e);
assert_eq!(out, "── freeswitch.log.1.xz\n");
}
#[test]
fn embedded_uuid_gets_its_own_color() {
let e = entry("aaaa", &format!("Peer UUID: {PEER}"), &[]);
let out = render(&printer(ColorMode::Always, false), &e);
let (r, g, b) = uuid_truecolor(PEER);
assert!(
out.contains(&format!("\x1b[38;2;{r};{g};{b}m{PEER}")),
"{out}"
);
}
#[test]
fn embedded_uuid_left_alone_without_color() {
let e = entry("aaaa", &format!("Peer UUID: {PEER}"), &[]);
let out = render(&printer(ColorMode::Never, false), &e);
assert!(out.contains(&format!("Peer UUID: {PEER}")), "{out}");
assert!(!out.contains("\x1b["), "{out}");
}
#[test]
fn pass_and_fail_are_colored_in_attached_lines() {
let e = entry(
"u",
"Dialplan: parsing",
&["Regex (PASS) a", "Regex (FAIL) b"],
);
let out = render(&printer(ColorMode::Always, true), &e);
assert!(
out.contains(&format!("{BRIGHT_GREEN}(PASS){RESET}")),
"{out}"
);
assert!(out.contains(&format!("{RED}(FAIL){RESET}")), "{out}");
}
#[test]
fn long_variable_values_are_not_truncated() {
let long = "x".repeat(500);
let mut e = entry("u", "CHANNEL_DATA:", &[]);
e.block = Some(Block::ChannelData {
fields: Vec::new(),
variables: vec![("variable_sip_multipart".to_string(), long.clone())],
});
let out = render(&printer(ColorMode::Never, true), &e);
assert!(out.contains(&long), "{out}");
assert!(!out.contains("..."), "{out}");
}
pub fn filter(p: FilterParams) -> FilterConfig {
FilterConfig::new(FilterParams {
uuid_strict: true,
..p
})
.unwrap()
}
#[test]
fn uuid_or_matches_any_needle() {
let f = filter(FilterParams {
uuid: vec!["aaaa".into(), "bbbb".into()],
..Default::default()
});
assert!(f.matches(&entry("xx-bbbb-yy", "msg", &[])));
assert!(f.matches(&entry("aaaa-0000", "msg", &[])));
assert!(!f.matches(&entry("cccc-0000", "msg", &[])));
}
#[test]
fn uuid_match_is_case_insensitive() {
let f = filter(FilterParams {
uuid: vec!["AAAABBBB".into()],
..Default::default()
});
assert!(f.matches(&entry("aaaabbbb-2222-3333-4444-555555555555", "msg", &[])));
}
#[test]
fn uuid_strict_ignores_message_body() {
let mut f = filter(FilterParams {
uuid: vec!["dead".into()],
..Default::default()
});
assert!(!f.matches(&entry("0000", "peer dead leg", &[])));
f.uuid_strict = false;
assert!(f.matches(&entry("0000", "peer dead leg", &[])));
}
#[test]
fn fgrep_into_blocks_only_with_match_blocks() {
let mut f = filter(FilterParams {
fgrep: Some("m=audio".into()),
..Default::default()
});
let e = entry("u", "Remote SDP:", &["v=0", "m=audio 5004 RTP/AVP 0"]);
assert!(!f.matches(&e));
f.match_blocks = true;
assert!(f.matches(&e));
}
#[test]
fn fgrep_is_case_insensitive() {
let f = filter(FilterParams {
fgrep: Some("RECEIVING INVITE".into()),
..Default::default()
});
assert!(f.matches(&entry("u", "receiving invite from 192.0.2.1", &[])));
}
#[test]
fn category_matches_any_of_several() {
let f = filter(FilterParams {
category: vec!["execute".into(), "dialplan".into()],
..Default::default()
});
let mut e = entry("u", "msg", &[]);
e.message_kind = MessageKind::Dialplan {
channel: "sofia/internal/1001".to_string(),
detail: "parsing".to_string(),
};
assert!(f.matches(&e));
e.message_kind = MessageKind::General;
assert!(!f.matches(&e));
}
#[test]
fn for_discovery_clears_category_and_loosens() {
let f = filter(FilterParams {
category: vec!["execute".into()],
uuid: vec!["seed".into()],
..Default::default()
});
let d = f.for_discovery();
assert!(d.category.is_empty());
assert!(!d.uuid_strict);
assert!(d.match_blocks);
assert!(d.matches(&entry("0000", "found seed here", &[])));
}
fn codec_entry(names: &[&str], matched: &[&str]) -> LogEntry {
let offer = |name: &str| {
freeswitch_log_parser::CodecOffer::parse(
freeswitch_log_parser::CodecMedia::Audio,
&format!("{name}:0:8000:20:64000:1"),
)
.expect("token parses")
};
let mut e = entry("u", "Audio Codec Compare", &[]);
e.block = Some(Block::CodecNegotiation {
media: freeswitch_log_parser::CodecMedia::Audio,
comparisons: names.iter().map(|n| (offer(n), offer("PCMU"))).collect(),
matched: matched.iter().map(|n| offer(n)).collect(),
near_matched: Vec::new(),
});
e
}
#[test]
fn codec_filter_matches_offers_and_matches() {
let f = filter(FilterParams {
codec: vec!["opus".into()],
..Default::default()
});
assert!(f.matches(&codec_entry(&["opus"], &[])), "a remote offer");
assert!(f.matches(&codec_entry(&["G722"], &["opus"])), "the winner");
assert!(!f.matches(&codec_entry(&["G722"], &["G722"])));
}
#[test]
fn codec_filter_is_case_insensitive() {
let f = filter(FilterParams {
codec: vec!["OPUS".into()],
..Default::default()
});
assert!(f.matches(&codec_entry(&["opus"], &[])));
}
#[test]
fn codec_filter_ignores_entries_without_media_blocks() {
let f = filter(FilterParams {
codec: vec!["opus".into()],
..Default::default()
});
assert!(!f.matches(&entry("u", "opus appears only in the text", &[])));
}
const CALL: &str = "aaaaaaaa-1111-1111-1111-111111111111";
fn hidden(f: &FilterConfig, e: &LogEntry) -> Option<Hidden> {
match f.verdict(e) {
Verdict::Hidden(h) => Some(h),
_ => None,
}
}
fn grep(pattern: &str) -> Option<regex::Regex> {
Some(regex::Regex::new(pattern).expect("test pattern compiles"))
}
#[test]
fn a_pattern_in_the_uuid_column_is_counted() {
let f = filter(FilterParams {
grep: grep(CALL),
..Default::default()
});
assert_eq!(
hidden(&f, &entry(CALL, "Activating RTCP", &[])),
Some(Hidden::PatternInUuid)
);
}
#[test]
fn the_uuid_column_probe_ignores_case() {
let f = filter(FilterParams {
grep: grep(&CALL.to_uppercase()),
..Default::default()
});
assert_eq!(
hidden(&f, &entry(CALL, "Activating RTCP", &[])),
Some(Hidden::PatternInUuid)
);
}
#[test]
fn a_pattern_in_an_attached_line_is_counted_until_match_blocks() {
let mut f = filter(FilterParams {
fgrep: Some("m=audio".into()),
..Default::default()
});
let e = entry("u", "Remote SDP:", &["v=0", "m=audio 5004 RTP/AVP 0"]);
assert_eq!(hidden(&f, &e), Some(Hidden::PatternInBlocks));
f.match_blocks = true;
assert_eq!(hidden(&f, &e), None, "the flag is already in effect");
}
#[test]
fn the_uuid_column_wins_over_the_attached_bucket() {
let f = filter(FilterParams {
fgrep: Some(CALL.into()),
..Default::default()
});
let e = entry(CALL, "CHANNEL_DATA:", &[&format!("Unique-ID: [{CALL}]")]);
assert_eq!(hidden(&f, &e), Some(Hidden::PatternInUuid));
}
#[test]
fn a_rejection_on_another_predicate_advertises_nothing() {
let f = filter(FilterParams {
grep: grep(CALL),
min_level: Some(LogLevel::Err),
..Default::default()
});
let mut e = entry(CALL, "Activating RTCP", &[]);
e.level = Some(LogLevel::Debug);
assert_eq!(hidden(&f, &e), None);
}
#[test]
fn conjunct_patterns_widen_together_or_not_at_all() {
let f = filter(FilterParams {
fgrep: Some("hangup".into()),
grep: grep(CALL),
..Default::default()
});
assert_eq!(hidden(&f, &entry(CALL, "Activating RTCP", &[])), None);
}
#[test]
fn a_uuid_named_in_the_body_is_counted() {
let f = filter(FilterParams {
uuid: vec![CALL.into()],
..Default::default()
});
assert_eq!(
hidden(&f, &entry("bbbb", &format!("Bridging to {CALL}"), &[])),
Some(Hidden::UuidInBody)
);
assert_eq!(hidden(&f, &entry("bbbb", "unrelated", &[])), None);
}
#[test]
fn a_uuid_inside_a_pattern_names_the_command() {
let f = filter(FilterParams {
grep: grep(&format!("Hangup on {CALL}")),
..Default::default()
});
assert_eq!(f.suggested_uuid(), Some(CALL));
}
#[test]
fn no_command_for_a_uuid_already_being_filtered_on() {
let f = filter(FilterParams {
uuid: vec![CALL.to_uppercase()],
fgrep: Some(CALL.into()),
..Default::default()
});
assert_eq!(f.suggested_uuid(), None);
let plain = filter(FilterParams {
fgrep: Some("receiving invite".into()),
..Default::default()
});
assert_eq!(plain.suggested_uuid(), None, "no uuid in the pattern");
}
}