use std::collections::VecDeque;
use std::io::{self, Write};
use freeswitch_log_parser::{LogEntry, MessageKind, SegmentTracker, SessionSnapshot};
use crate::output::{EntryPrinter, FilterConfig, Hidden, Verdict};
use crate::separator_entry;
#[derive(Default)]
pub struct HiddenCounts {
pub pattern_in_uuid: u64,
pub pattern_in_blocks: u64,
pub uuid_in_body: u64,
}
impl HiddenCounts {
fn tally(&mut self, h: Hidden) {
match h {
Hidden::PatternInUuid => self.pattern_in_uuid += 1,
Hidden::PatternInBlocks => self.pattern_in_blocks += 1,
Hidden::UuidInBody => self.uuid_in_body += 1,
}
}
}
struct Buffered {
bytes: Vec<u8>,
seg: Option<(usize, String)>,
date: String,
}
pub struct Emitter<'a> {
printer: &'a EntryPrinter,
filter: &'a FilterConfig,
seg_tracker: &'a SegmentTracker,
stats_only: bool,
before: usize,
after: usize,
ring: VecDeque<Buffered>,
remaining_after: usize,
has_printed: bool,
contiguous: bool,
last_seg: Option<usize>,
last_date: String,
pub count: u64,
pub matched: u64,
pub hidden: HiddenCounts,
}
impl<'a> Emitter<'a> {
pub fn new(
printer: &'a EntryPrinter,
filter: &'a FilterConfig,
seg_tracker: &'a SegmentTracker,
stats_only: bool,
before: usize,
after: usize,
) -> Self {
Emitter {
printer,
filter,
seg_tracker,
stats_only,
before,
after,
ring: VecDeque::with_capacity(before),
remaining_after: 0,
has_printed: false,
contiguous: false,
last_seg: None,
last_date: String::new(),
count: 0,
matched: 0,
hidden: HiddenCounts::default(),
}
}
fn has_context(&self) -> bool {
self.before > 0 || self.after > 0
}
pub fn on_entry(
&mut self,
out: &mut dyn Write,
entry: &LogEntry,
session: Option<&SessionSnapshot>,
) -> io::Result<()> {
self.count += 1;
match self.filter.verdict(entry) {
Verdict::Match => {}
verdict => {
if let Verdict::Hidden(h) = verdict {
self.hidden.tally(h);
}
if self.has_context() {
self.feed_nonmatch(out, entry, session)?;
}
return Ok(());
}
}
self.matched += 1;
if self.stats_only {
return Ok(());
}
self.feed_match(out, entry, session)
}
fn feed_match(
&mut self,
out: &mut dyn Write,
entry: &LogEntry,
session: Option<&SessionSnapshot>,
) -> io::Result<()> {
if self.has_context() && self.has_printed && !self.contiguous {
writeln!(out, "--")?;
}
let ring: Vec<Buffered> = self.ring.drain(..).collect();
for b in &ring {
self.emit_bytes(out, b)?;
}
self.emit_entry(out, entry, session)?;
self.remaining_after = self.after;
self.has_printed = true;
self.contiguous = true;
Ok(())
}
fn feed_nonmatch(
&mut self,
out: &mut dyn Write,
entry: &LogEntry,
session: Option<&SessionSnapshot>,
) -> io::Result<()> {
if self.remaining_after > 0 {
self.remaining_after -= 1;
self.has_printed = true;
self.emit_entry(out, entry, session)
} else {
if self.before > 0 {
let mut bytes = Vec::new();
self.printer.print_entry(&mut bytes, entry, session, None)?;
let seg = self
.seg_tracker
.segment_for_line(entry.line_number)
.map(|(i, n)| (i, n.to_string()));
if self.ring.len() == self.before {
self.ring.pop_front();
}
self.ring.push_back(Buffered {
bytes,
seg,
date: entry.timestamp.clone(),
});
}
self.contiguous = false;
Ok(())
}
}
fn emit_entry(
&mut self,
out: &mut dyn Write,
entry: &LogEntry,
session: Option<&SessionSnapshot>,
) -> io::Result<()> {
let seg = self
.seg_tracker
.segment_for_line(entry.line_number)
.map(|(i, n)| (i, n.to_string()));
self.print_separators(
out,
seg.as_ref().map(|(i, n)| (*i, n.as_str())),
&entry.timestamp,
)?;
self.printer.print_entry(out, entry, session, None)
}
fn emit_bytes(&mut self, out: &mut dyn Write, b: &Buffered) -> io::Result<()> {
self.print_separators(out, b.seg.as_ref().map(|(i, n)| (*i, n.as_str())), &b.date)?;
out.write_all(&b.bytes)
}
fn print_separators(
&mut self,
out: &mut dyn Write,
seg: Option<(usize, &str)>,
timestamp: &str,
) -> io::Result<()> {
if let Some((idx, name)) = seg {
if self.last_seg != Some(idx) {
self.last_seg = Some(idx);
let sep = separator_entry(MessageKind::FileChange, name.to_string());
self.printer.print_entry(out, &sep, None, None)?;
}
}
if timestamp.len() >= 10 {
let date = ×tamp[..10];
if date != self.last_date {
self.last_date = date.to_string();
let sep = separator_entry(MessageKind::DateChange, self.last_date.clone());
self.printer.print_entry(out, &sep, None, None)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use freeswitch_log_parser::{AttachedLines, LineKind, TrackedChain};
use crate::output::{ColorMode, FilterParams};
fn entry(uuid: &str) -> LogEntry {
LogEntry {
uuid: uuid.to_string(),
timestamp: String::new(),
level: None,
idle_pct: None,
source: None,
message: "x".to_string(),
kind: LineKind::Full,
message_kind: MessageKind::General,
block: None,
attached: AttachedLines::new(),
line_number: 0,
warnings: Vec::new(),
}
}
fn run(before: usize, after: usize, uuids: &[&str]) -> String {
let printer = EntryPrinter {
color: ColorMode::Never,
show_blocks: false,
show_session: false,
show_filename: false,
show_line_numbers: false,
};
let filter = crate::output::tests::filter(FilterParams {
uuid: vec!["m".into()],
..Default::default()
});
let (_chain, tracker) = TrackedChain::new(Vec::new());
let mut emitter = Emitter::new(&printer, &filter, &tracker, false, before, after);
let mut out: Vec<u8> = Vec::new();
for u in uuids {
emitter.on_entry(&mut out, &entry(u), None).unwrap();
}
String::from_utf8(out).unwrap()
}
#[test]
fn context_before_after() {
let out = run(1, 1, &["a", "b", "Mx", "c", "d"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains(" b "));
assert!(lines[1].contains(" Mx "));
assert!(lines[2].contains(" c "));
}
#[test]
fn no_context_prints_only_matches() {
let out = run(0, 0, &["a", "Mx", "b", "My"]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(!out.contains("--"));
}
const CALL: &str = "aaaaaaaa-1111-1111-1111-111111111111";
fn session_log() -> Vec<String> {
[
format!("{CALL} 2026-05-17 09:48:33.534240 99.99% [NOTICE] switch_channel.c:1118 New Channel sofia/internal/caller@example.test [{CALL}]"),
format!("{CALL} 2026-05-17 09:48:33.554242 99.99% [INFO] mod_dptools.c:1885 CHANNEL_DATA:"),
format!("{CALL} Unique-ID: [{CALL}]"),
format!("{CALL} 2026-05-17 09:48:34.000000 99.99% [NOTICE] switch_rtp.c:100 Audio Codec Compare PCMU"),
format!("{CALL} 2026-05-17 09:50:55.014000 99.99% [NOTICE] switch_core_session.c:1234 State Change CS_EXECUTE -> CS_DESTROY"),
]
.to_vec()
}
fn tally(filter: &FilterConfig) -> (u64, HiddenCounts) {
let printer = EntryPrinter {
color: ColorMode::Never,
show_blocks: false,
show_session: false,
show_filename: false,
show_line_numbers: false,
};
let (_chain, tracker) = TrackedChain::new(Vec::new());
let mut emitter = Emitter::new(&printer, filter, &tracker, false, 0, 0);
let mut out: Vec<u8> = Vec::new();
for e in freeswitch_log_parser::LogStream::new(session_log().into_iter()) {
emitter.on_entry(&mut out, &e, None).unwrap();
}
(emitter.matched, emitter.hidden)
}
#[test]
fn a_sessions_own_lines_are_fully_accounted_for() {
let (shown, hidden) = tally(&crate::output::tests::filter(FilterParams {
grep: Some(regex::Regex::new(CALL).expect("test pattern compiles")),
..Default::default()
}));
assert_eq!(shown, 1, "only the New Channel message names it");
assert_eq!(hidden.pattern_in_uuid, 3);
assert_eq!(hidden.pattern_in_blocks, 0);
let (by_uuid, _) = tally(&crate::output::tests::filter(FilterParams {
uuid: vec![CALL.into()],
..Default::default()
}));
assert_eq!(shown + hidden.pattern_in_uuid, by_uuid);
}
#[test]
fn divider_between_noncontiguous_groups() {
let out = run(1, 1, &["Mx", "a", "b", "c", "My"]);
assert!(out.contains("\n--\n"));
}
}