use std::collections::HashMap;
use std::path::Path;
pub type InviteHeaders = HashMap<String, Vec<(String, String)>>;
#[derive(Debug, Default)]
pub struct TraceTail {
last_len: u64,
}
impl TraceTail {
pub fn new() -> Self {
Self::default()
}
pub fn poll(&mut self, log_path: &Path) -> Option<InviteHeaders> {
let len = std::fs::metadata(log_path).ok()?.len();
if len <= self.last_len {
return None;
}
self.last_len = len;
let trace = std::fs::read_to_string(log_path).ok()?;
Some(parse_invites(&trace))
}
}
#[allow(clippy::while_let_on_iterator)]
pub fn parse_invites(trace: &str) -> InviteHeaders {
let mut out: InviteHeaders = HashMap::new();
let mut lines = trace.lines().map(strip_ansi).peekable();
while let Some(line) = lines.next() {
let line = line.trim_end_matches('\r');
if !is_invite_request_line(line) {
continue;
}
let mut headers: Vec<(String, String)> = Vec::new();
while let Some(raw) = lines.next() {
let h = raw.trim_end_matches('\r');
if h.is_empty() {
break; }
if (h.starts_with(' ') || h.starts_with('\t')) && !headers.is_empty() {
let last = headers.last_mut().unwrap();
last.1.push(' ');
last.1.push_str(h.trim());
} else if let Some((name, value)) = h.split_once(':') {
headers.push((name.trim().to_string(), value.trim().to_string()));
}
}
if let Some(call_id) = headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("Call-ID"))
.map(|(_, v)| v.clone())
{
out.entry(call_id).or_insert(headers);
}
}
out
}
pub fn headers_for<'a>(trace: &'a InviteHeaders, call_id: &str) -> &'a [(String, String)] {
trace.get(call_id).map(Vec::as_slice).unwrap_or(&[])
}
pub fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn is_invite_request_line(line: &str) -> bool {
line.starts_with("INVITE ") && line.ends_with(" SIP/2.0")
}
fn strip_ansi(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.next() == Some('[') {
for d in chars.by_ref() {
if ('@'..='~').contains(&d) {
break;
}
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const TRACE: &str = "\x1b[36;1m10:13:16.228#\r\n\
UDP 192.0.2.1:5060 -> 192.0.2.1:5070\r\n\
INVITE sip:bob@192.0.2.1:5070 SIP/2.0\r\n\
Via: SIP/2.0/UDP 192.0.2.1:5060;branch=z9hG4bK75826597781dd06f;rport\r\n\
To: <sip:bob@192.0.2.1:5070>\r\n\
From: <sip:alice@192.0.2.1:5060>;tag=17527a49416fad9c\r\n\
Call-ID: 6f30f685a993adf8\r\n\
CSeq: 63450 INVITE\r\n\
X-Trace-Id: spike-CAFEBABE-42\r\n\
Content-Type: application/sdp\r\n\
Content-Length: 330\r\n\
\r\n\
v=0\r\n\
o=- 1402857639 698399491 IN IP4 192.0.2.1\r\n";
#[test]
fn extracts_headers_keyed_by_call_id() {
let map = parse_invites(TRACE);
let h = headers_for(&map, "6f30f685a993adf8");
assert_eq!(header_value(h, "X-Trace-Id"), Some("spike-CAFEBABE-42"));
assert_eq!(header_value(h, "call-id"), Some("6f30f685a993adf8"));
assert!(header_value(h, "v").is_none());
}
#[test]
fn unknown_call_id_yields_empty() {
let map = parse_invites(TRACE);
assert!(headers_for(&map, "does-not-exist").is_empty());
}
#[test]
fn header_lookup_is_case_insensitive() {
let map = parse_invites(TRACE);
let h = headers_for(&map, "6f30f685a993adf8");
assert_eq!(header_value(h, "X-TRACE-ID"), Some("spike-CAFEBABE-42"));
}
#[test]
fn folded_header_is_joined() {
let trace = "INVITE sip:x SIP/2.0\nCall-ID: abc\nSubject: part one\n part two\n\n";
let map = parse_invites(trace);
let h = headers_for(&map, "abc");
assert_eq!(header_value(h, "Subject"), Some("part one part two"));
}
#[test]
fn first_invite_per_call_id_wins() {
let trace = "INVITE sip:x SIP/2.0\r\nCall-ID: dup\r\nX-N: first\r\n\r\n\
INVITE sip:x SIP/2.0\r\nCall-ID: dup\r\nX-N: second\r\n\r\n";
let map = parse_invites(trace);
assert_eq!(header_value(headers_for(&map, "dup"), "X-N"), Some("first"));
}
#[test]
fn non_invite_messages_are_ignored() {
let trace = "SIP/2.0 180 Ringing\r\nCall-ID: ring\r\n\r\n\
BYE sip:x SIP/2.0\r\nCall-ID: bye\r\n\r\n";
assert!(parse_invites(trace).is_empty());
}
#[test]
fn trace_tail_reparses_only_on_growth() {
use std::io::Write;
let path = std::env::temp_dir().join(format!(
"ringo_tracetail_{}_{}.log",
std::process::id(),
line!()
));
let _ = std::fs::remove_file(&path);
let mut tail = TraceTail::new();
assert!(tail.poll(&path).is_none());
let mut f = std::fs::File::create(&path).unwrap();
write!(f, "INVITE sip:x SIP/2.0\r\nCall-ID: c1\r\nX-N: a\r\n\r\n").unwrap();
f.flush().unwrap();
let first = tail.poll(&path).expect("grew → Some");
assert_eq!(header_value(headers_for(&first, "c1"), "X-N"), Some("a"));
assert!(tail.poll(&path).is_none());
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
write!(f, "INVITE sip:y SIP/2.0\r\nCall-ID: c2\r\nX-N: b\r\n\r\n").unwrap();
f.flush().unwrap();
let second = tail.poll(&path).expect("grew again → Some");
assert_eq!(header_value(headers_for(&second, "c1"), "X-N"), Some("a"));
assert_eq!(header_value(headers_for(&second, "c2"), "X-N"), Some("b"));
let _ = std::fs::remove_file(&path);
}
}