From 40274f2adfff18119fba46c9544ef7567c223ac8 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sat, 6 Jun 2026 09:27:29 -0500 Subject: [PATCH 1/6] docs: Update Formatter design to be stateful --- docs/design/candumpr/02-architecture.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/design/candumpr/02-architecture.md b/docs/design/candumpr/02-architecture.md index 3ed708c..185acaa 100644 --- a/docs/design/candumpr/02-architecture.md +++ b/docs/design/candumpr/02-architecture.md @@ -246,14 +246,15 @@ rotated due to size or duration limits. ## Formatter detail -The Formatter converts a CanFrame into a byte array to be written. It is stateless (or nearly so) -and is owned by the Pipeline, not by individual Sinks. All configuration (interface names, timestamp -mode, format) is provided at construction time. +The Formatter converts a CanFrame into a byte array to be written. It is owned by the Pipeline, not +by individual Sinks, and all configuration (interface names, timestamp mode, format) is provided at +construction time. Its only mutable state is the relative-timestamp reference used by the delta and +zero timestamp modes, so `format` takes `&mut self`. The Formatter trait has two methods: -* `format(&self, frame: &CanFrame, buf: &mut Vec)` -- append the formatted frame to the buffer. - The buffer may contain multiple frames. A frame is never split across multiple writes. +* `format(&mut self, frame: &CanFrame, buf: &mut Vec)` -- append the formatted frame to the + buffer. The buffer may contain multiple frames. A frame is never split across multiple writes. * `header(&self) -> Option<&[u8]>` -- return the file header for formats that need one (e.g. PCAP), or None for formats that do not (e.g. candump, ASC). The Pipeline provides this header blob to each Sink at construction time, and the Sink writes it when opening a new file. From f50291635d67348eda93ca3dd0c27b15e41ed357 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sat, 6 Jun 2026 09:49:16 -0500 Subject: [PATCH 2/6] Add TimestampClock to resolve different timestamp modes --- candumpr/src/format.rs | 146 +++++++++++++++++++++++++++++++++++++++ candumpr/src/recv/mod.rs | 2 +- 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/candumpr/src/format.rs b/candumpr/src/format.rs index dd1d29f..8acee88 100644 --- a/candumpr/src/format.rs +++ b/candumpr/src/format.rs @@ -1,6 +1,93 @@ use std::io::Write; +use std::time::Duration; use crate::frame::CanFrame; +use crate::recv::Timestamp; + +/// How candumpr renders the timestamp prefix on candump-format output. +/// +/// Only the candump formats consult this. ASC and PCAP carry their own intrinsic timestamps. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum TimestampMode { + /// The frame's absolute receive time + #[default] + Absolute, + /// Time since the previous frame + Delta, + /// Time since the first frame + Zero, +} + +/// A resolved timestamp ready to render +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DisplayTimestamp { + /// The frame's absolute receive time. + Absolute(Timestamp), + /// The elapsed duration since the reference frame. + Relative(Duration), +} + +/// Tracks the reference timestamp needed to render relative timestamps ([TimestampMode::Delta] and +/// [TimestampMode::Zero]) +struct TimestampClock { + mode: TimestampMode, + /// For Zero: the first frame's time. For Delta: the previous frame's time. + reference: Option, +} + +impl TimestampClock { + fn new(mode: TimestampMode) -> Self { + Self { + mode, + reference: None, + } + } + + /// Resolve the timestamp to render for `ts`, advancing internal state as needed. + /// + /// The first frame in a relative mode resolves to a zero duration, matching candump. + fn resolve(&mut self, ts: Timestamp) -> DisplayTimestamp { + match self.mode { + TimestampMode::Absolute => DisplayTimestamp::Absolute(ts), + TimestampMode::Zero => { + let first = *self.reference.get_or_insert(ts); + DisplayTimestamp::Relative(elapsed(first, ts)) + } + TimestampMode::Delta => { + let prev = self.reference.replace(ts).unwrap_or(ts); + DisplayTimestamp::Relative(elapsed(prev, ts)) + } + } + } +} + +/// Non-negative elapsed duration from `start` to `end`. +/// +/// Clamps to zero if `end` precedes `start` (e.g. a backward system-clock jump). +fn elapsed(start: Timestamp, end: Timestamp) -> Duration { + let start_ns = start.sec as i128 * 1_000_000_000 + start.nsec as i128; + let end_ns = end.sec as i128 * 1_000_000_000 + end.nsec as i128; + let diff_ns = (end_ns - start_ns).max(0) as u128; + Duration::new( + (diff_ns / 1_000_000_000) as u64, + (diff_ns % 1_000_000_000) as u32, + ) +} + +/// Write the candump-style `(seconds.microseconds) ` timestamp prefix, including the trailing space. +/// +/// Absolute times print unpadded seconds; relative times zero-pad seconds to three digits, matching +/// candump's `(%llu.%06llu)` and `(%03llu.%06llu)` respectively. +fn write_timestamp(buf: &mut Vec, ts: DisplayTimestamp) { + match ts { + DisplayTimestamp::Absolute(t) => { + write!(buf, "({}.{:06}) ", t.sec, t.nsec / 1000).unwrap(); + } + DisplayTimestamp::Relative(d) => { + write!(buf, "({:03}.{:06}) ", d.as_secs(), d.subsec_micros()).unwrap(); + } + } +} /// Formats received CAN frames into bytes for writing. pub trait Formatter { @@ -70,6 +157,10 @@ mod tests { use crate::frame::Direction; use crate::recv::Timestamp; + fn ts(sec: i64, nsec: i64) -> Timestamp { + Timestamp { sec, nsec } + } + #[test] fn canutils_format_basic() { let frame = CanFrame { @@ -141,4 +232,59 @@ mod tests { fmt.format(&frame, &mut buf); assert_eq!(String::from_utf8(buf).unwrap(), "(0.000000) can0 123#AB\n"); } + + #[test] + fn absolute_returns_frame_time_unchanged() { + let mut clock = TimestampClock::new(TimestampMode::Absolute); + assert_eq!( + clock.resolve(ts(1_700_000_000, 123_456_000)), + DisplayTimestamp::Absolute(ts(1_700_000_000, 123_456_000)) + ); + } + + #[test] + fn zero_is_elapsed_since_first_frame() { + let mut clock = TimestampClock::new(TimestampMode::Zero); + assert_eq!( + clock.resolve(ts(100, 0)), + DisplayTimestamp::Relative(Duration::ZERO) + ); + assert_eq!( + clock.resolve(ts(100, 250_000_000)), + DisplayTimestamp::Relative(Duration::from_micros(250_000)) + ); + // Relative to the first frame (100), not the previous one. + assert_eq!( + clock.resolve(ts(105, 0)), + DisplayTimestamp::Relative(Duration::from_secs(5)) + ); + } + + #[test] + fn delta_is_gap_between_consecutive_frames() { + let mut clock = TimestampClock::new(TimestampMode::Delta); + assert_eq!( + clock.resolve(ts(100, 0)), + DisplayTimestamp::Relative(Duration::ZERO) + ); + assert_eq!( + clock.resolve(ts(100, 250_000_000)), + DisplayTimestamp::Relative(Duration::from_micros(250_000)) + ); + // Relative to the previous frame (100.25), not the first one. + assert_eq!( + clock.resolve(ts(102, 0)), + DisplayTimestamp::Relative(Duration::from_micros(1_750_000)) + ); + } + + #[test] + fn relative_clamps_backward_clock_to_zero() { + let mut clock = TimestampClock::new(TimestampMode::Delta); + clock.resolve(ts(100, 0)); + assert_eq!( + clock.resolve(ts(99, 0)), + DisplayTimestamp::Relative(Duration::ZERO) + ); + } } diff --git a/candumpr/src/recv/mod.rs b/candumpr/src/recv/mod.rs index be7ac1e..24ac981 100644 --- a/candumpr/src/recv/mod.rs +++ b/candumpr/src/recv/mod.rs @@ -21,7 +21,7 @@ pub struct FrameMeta { } /// A timestamp with seconds and nanoseconds since the Unix epoch. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Timestamp { pub sec: i64, pub nsec: i64, From ea8574850086b8cc0579ec34312e853a941a20ba Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sat, 6 Jun 2026 10:00:58 -0500 Subject: [PATCH 3/6] Add timestamp mode support to CanutilsFileFormatter --- candumpr/src/format.rs | 160 ++++++++++++++++++++++----------------- candumpr/src/main.rs | 4 +- candumpr/src/pipeline.rs | 14 +++- 3 files changed, 102 insertions(+), 76 deletions(-) diff --git a/candumpr/src/format.rs b/candumpr/src/format.rs index 8acee88..68921ce 100644 --- a/candumpr/src/format.rs +++ b/candumpr/src/format.rs @@ -92,7 +92,7 @@ fn write_timestamp(buf: &mut Vec, ts: DisplayTimestamp) { /// Formats received CAN frames into bytes for writing. pub trait Formatter { /// Append the formatted representation of `frame` to `buf`. - fn format(&self, frame: &CanFrame, buf: &mut Vec); + fn format(&mut self, frame: &CanFrame, buf: &mut Vec); /// Optional header bytes written once at the start of each output stream, before any frames. /// @@ -102,45 +102,46 @@ pub trait Formatter { } } -/// Formats frames in the can-utils candump file format. +/// The display CAN ID (with flags masked appropriately) and the zero-padded hex width to render it. /// -/// Output format: `(SECONDS.MICROSECONDS) IFACE CANID#DATA\n` +/// Error frames keep the error flag and render at width 8; extended frames render at width 8; +/// standard frames render at width 3. +fn canid_and_width(can_id: u32) -> (u32, usize) { + if can_id & libc::CAN_ERR_FLAG != 0 { + (can_id & (libc::CAN_ERR_MASK | libc::CAN_ERR_FLAG), 8) + } else if can_id & libc::CAN_EFF_FLAG != 0 { + (can_id & libc::CAN_EFF_MASK, 8) + } else { + (can_id & libc::CAN_SFF_MASK, 3) + } +} + +/// Formats frames in the can-utils candump file (log) format. /// -/// Example: `(1616161616.123456) can0 18FECA00#AABB0011` -pub struct CanutilsFormatter { +/// Output format: `(TIMESTAMP) IFACE CANID#DATA\n`, e.g. `(1616161616.123456) can0 18FECA00#AABB0011`. +/// The timestamp is rendered according to the configured [TimestampMode]. +pub struct CanutilsFileFormatter { iface_names: Vec, + clock: TimestampClock, } -impl CanutilsFormatter { - pub fn new(iface_names: Vec) -> Self { - Self { iface_names } +impl CanutilsFileFormatter { + pub fn new(iface_names: Vec, timestamp: TimestampMode) -> Self { + Self { + iface_names, + clock: TimestampClock::new(timestamp), + } } } -impl Formatter for CanutilsFormatter { - fn format(&self, frame: &CanFrame, buf: &mut Vec) { - let ts = &frame.timestamp; - let iface = &self.iface_names[frame.sock_id]; - let can_id = frame.raw.can_id; - - let (id, width) = if can_id & libc::CAN_ERR_FLAG != 0 { - (can_id & (libc::CAN_ERR_MASK | libc::CAN_ERR_FLAG), 8) - } else if can_id & libc::CAN_EFF_FLAG != 0 { - (can_id & libc::CAN_EFF_MASK, 8) - } else { - (can_id & libc::CAN_SFF_MASK, 3) - }; +impl Formatter for CanutilsFileFormatter { + fn format(&mut self, frame: &CanFrame, buf: &mut Vec) { + let display = self.clock.resolve(frame.timestamp); + write_timestamp(buf, display); - write!( - buf, - "({}.{:06}) {} {:0width$X}#", - ts.sec, - ts.nsec / 1000, - iface, - id, - width = width, - ) - .unwrap(); + let iface = &self.iface_names[frame.sock_id]; + let (id, width) = canid_and_width(frame.raw.can_id); + write!(buf, "{iface} {id:0width$X}#").unwrap(); for i in 0..frame.raw.len as usize { write!(buf, "{:02X}", frame.raw.data[i]).unwrap(); } @@ -161,21 +162,28 @@ mod tests { Timestamp { sec, nsec } } + fn frame(sock_id: usize, timestamp: Timestamp, can_id: u32, data: &[u8]) -> CanFrame { + CanFrame { + sock_id, + timestamp, + direction: Direction::Rx, + raw: LinuxCanFrame::new(can_id, data), + } + } + #[test] fn canutils_format_basic() { - let frame = CanFrame { - sock_id: 0, - timestamp: Timestamp { - sec: 1616161616, - nsec: 123000, - }, - direction: Direction::Rx, - raw: LinuxCanFrame::new(0x18FECA00 | libc::CAN_EFF_FLAG, &[0xAA, 0xBB, 0x00, 0x11]), - }; - let names = vec!["can0".to_string()]; - let fmt = CanutilsFormatter::new(names); + let mut fmt = CanutilsFileFormatter::new(vec!["can0".to_string()], TimestampMode::Absolute); let mut buf = Vec::new(); - fmt.format(&frame, &mut buf); + fmt.format( + &frame( + 0, + ts(1616161616, 123000), + 0x18FECA00 | libc::CAN_EFF_FLAG, + &[0xAA, 0xBB, 0x00, 0x11], + ), + &mut buf, + ); assert_eq!( String::from_utf8(buf).unwrap(), "(1616161616.000123) can0 18FECA00#AABB0011\n" @@ -184,16 +192,15 @@ mod tests { #[test] fn canutils_format_empty_data() { - let frame = CanFrame { - sock_id: 1, - timestamp: Timestamp { sec: 0, nsec: 0 }, - direction: Direction::Rx, - raw: LinuxCanFrame::new(0x123 | libc::CAN_EFF_FLAG, &[]), - }; - let names = vec!["vcan0".to_string(), "vcan1".to_string()]; - let fmt = CanutilsFormatter::new(names); + let mut fmt = CanutilsFileFormatter::new( + vec!["vcan0".to_string(), "vcan1".to_string()], + TimestampMode::Absolute, + ); let mut buf = Vec::new(); - fmt.format(&frame, &mut buf); + fmt.format( + &frame(1, ts(0, 0), 0x123 | libc::CAN_EFF_FLAG, &[]), + &mut buf, + ); assert_eq!( String::from_utf8(buf).unwrap(), "(0.000000) vcan1 00000123#\n" @@ -202,16 +209,12 @@ mod tests { #[test] fn canutils_format_error_frame_keeps_err_flag() { - let frame = CanFrame { - sock_id: 0, - timestamp: Timestamp { sec: 1, nsec: 0 }, - direction: Direction::Rx, - raw: LinuxCanFrame::new(libc::CAN_ERR_FLAG | 0x40, &[0, 0, 0, 0, 0, 0, 0, 0]), - }; - let names = vec!["can0".to_string()]; - let fmt = CanutilsFormatter::new(names); + let mut fmt = CanutilsFileFormatter::new(vec!["can0".to_string()], TimestampMode::Absolute); let mut buf = Vec::new(); - fmt.format(&frame, &mut buf); + fmt.format( + &frame(0, ts(1, 0), libc::CAN_ERR_FLAG | 0x40, &[0; 8]), + &mut buf, + ); assert_eq!( String::from_utf8(buf).unwrap(), "(1.000000) can0 20000040#0000000000000000\n" @@ -220,19 +223,36 @@ mod tests { #[test] fn canutils_format_standard_frame_uses_three_digits() { - let frame = CanFrame { - sock_id: 0, - timestamp: Timestamp { sec: 0, nsec: 0 }, - direction: Direction::Rx, - raw: LinuxCanFrame::new(0x123, &[0xAB]), - }; - let names = vec!["can0".to_string()]; - let fmt = CanutilsFormatter::new(names); + let mut fmt = CanutilsFileFormatter::new(vec!["can0".to_string()], TimestampMode::Absolute); let mut buf = Vec::new(); - fmt.format(&frame, &mut buf); + fmt.format(&frame(0, ts(0, 0), 0x123, &[0xAB]), &mut buf); assert_eq!(String::from_utf8(buf).unwrap(), "(0.000000) can0 123#AB\n"); } + #[test] + fn canutils_format_delta_timestamps() { + let mut fmt = CanutilsFileFormatter::new(vec!["can0".to_string()], TimestampMode::Delta); + let mut buf = Vec::new(); + fmt.format(&frame(0, ts(100, 0), 0x123, &[0xAB]), &mut buf); + fmt.format(&frame(0, ts(100, 250_000_000), 0x123, &[0xAB]), &mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + "(000.000000) can0 123#AB\n(000.250000) can0 123#AB\n" + ); + } + + #[test] + fn canutils_format_zero_timestamps() { + let mut fmt = CanutilsFileFormatter::new(vec!["can0".to_string()], TimestampMode::Zero); + let mut buf = Vec::new(); + fmt.format(&frame(0, ts(100, 0), 0x123, &[0xAB]), &mut buf); + fmt.format(&frame(0, ts(105, 0), 0x123, &[0xAB]), &mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + "(000.000000) can0 123#AB\n(005.000000) can0 123#AB\n" + ); + } + #[test] fn absolute_returns_frame_time_unchanged() { let mut clock = TimestampClock::new(TimestampMode::Absolute); diff --git a/candumpr/src/main.rs b/candumpr/src/main.rs index 1271907..8e8597e 100644 --- a/candumpr/src/main.rs +++ b/candumpr/src/main.rs @@ -5,7 +5,7 @@ use std::time::Duration; use candumpr::can; use candumpr::errframe::{BusState, ErrorFrame}; -use candumpr::format::{CanutilsFormatter, Formatter}; +use candumpr::format::{CanutilsFileFormatter, Formatter, TimestampMode}; use candumpr::frame::CanFrame; use candumpr::pipeline::Pipeline; use candumpr::recv::netlink::{self, LinkEvent}; @@ -157,7 +157,7 @@ fn main() -> ExitCode { // Last logged bus state per sock_id, so we log only transitions. let mut bus_state: Vec = vec![BusState::default(); names.len()]; - let formatter = CanutilsFormatter::new(cli.interfaces); + let formatter = CanutilsFileFormatter::new(cli.interfaces, TimestampMode::Absolute); let header = formatter.header().map(|h| h.to_vec()); let sink = Sink::new( StdoutWriter::new(), diff --git a/candumpr/src/pipeline.rs b/candumpr/src/pipeline.rs index 3cbdbc3..42d4699 100644 --- a/candumpr/src/pipeline.rs +++ b/candumpr/src/pipeline.rs @@ -109,7 +109,7 @@ mod tests { use super::*; use crate::can::LinuxCanFrame; - use crate::format::CanutilsFormatter; + use crate::format::{CanutilsFileFormatter, TimestampMode}; use crate::frame::Direction; use crate::sink::Sink; use crate::test_util::TestBufWriter; @@ -140,7 +140,7 @@ mod tests { } fn formatted(names: &[String], frames: &[&CanFrame]) -> Vec { - let fmt = CanutilsFormatter::new(names.to_vec()); + let mut fmt = CanutilsFileFormatter::new(names.to_vec(), TimestampMode::Absolute); let mut buf = Vec::new(); for frame in frames { fmt.format(frame, &mut buf); @@ -156,7 +156,10 @@ mod tests { "can2".to_string(), "can3".to_string(), ]; - let mut pipeline = Pipeline::new(CanutilsFormatter::new(names.clone()), vec![sink()]); + let mut pipeline = Pipeline::new( + CanutilsFileFormatter::new(names.clone(), TimestampMode::Absolute), + vec![sink()], + ); let frames = vec![frame(0, 0x100, &[0x01]), frame(3, 0x200, &[0x02])]; pipeline.write_batch(&frames).unwrap(); @@ -169,7 +172,10 @@ mod tests { fn per_interface_dispatches_by_sock_id() { let names = vec!["can0".to_string(), "can1".to_string(), "can2".to_string()]; let sinks = vec![sink(), sink(), sink()]; - let mut pipeline = Pipeline::new(CanutilsFormatter::new(names.clone()), sinks); + let mut pipeline = Pipeline::new( + CanutilsFileFormatter::new(names.clone(), TimestampMode::Absolute), + sinks, + ); let frames = vec![ frame(0, 0x100, &[0x0A]), From 25b9d1fd486643705a355cd307244fd8502ba375 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sat, 6 Jun 2026 10:07:48 -0500 Subject: [PATCH 4/6] Add can-utils console formatter --- candumpr/src/format.rs | 100 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/candumpr/src/format.rs b/candumpr/src/format.rs index 68921ce..acd06c8 100644 --- a/candumpr/src/format.rs +++ b/candumpr/src/format.rs @@ -1,6 +1,7 @@ use std::io::Write; use std::time::Duration; +use crate::errframe::ErrorFrame; use crate::frame::CanFrame; use crate::recv::Timestamp; @@ -149,6 +150,48 @@ impl Formatter for CanutilsFileFormatter { } } +/// Formats frames in a human-readable, candump-console-derived format. +/// +/// We try to match can-utils console format, but I'm not going to try to reproduce every quirk of +/// its spacing. +/// +/// Output format: `(TIMESTAMP) IFACE CANID [LEN] B0 B1 ...`, e.g. +/// `(1616161616.123456) can0 18FECA00 [4] AA BB 00 11`. +/// +/// Error frames append `ERRORFRAME` and a decoded description on the following, tab-indented line. +pub struct CanutilsConsoleFormatter { + iface_names: Vec, + clock: TimestampClock, +} + +impl CanutilsConsoleFormatter { + pub fn new(iface_names: Vec, timestamp: TimestampMode) -> Self { + Self { + iface_names, + clock: TimestampClock::new(timestamp), + } + } +} + +impl Formatter for CanutilsConsoleFormatter { + fn format(&mut self, frame: &CanFrame, buf: &mut Vec) { + let display = self.clock.resolve(frame.timestamp); + write_timestamp(buf, display); + + let iface = &self.iface_names[frame.sock_id]; + let (id, width) = canid_and_width(frame.raw.can_id); + let len = frame.raw.len as usize; + write!(buf, "{iface} {id:0width$X} [{len}]").unwrap(); + for i in 0..len { + write!(buf, " {:02X}", frame.raw.data[i]).unwrap(); + } + if let Some(err) = ErrorFrame::parse(&frame.raw) { + write!(buf, " ERRORFRAME\n\t{err}").unwrap(); + } + buf.push(b'\n'); + } +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; @@ -307,4 +350,61 @@ mod tests { DisplayTimestamp::Relative(Duration::ZERO) ); } + + #[test] + fn console_format_standard_data_frame() { + let mut fmt = + CanutilsConsoleFormatter::new(vec!["can0".to_string()], TimestampMode::Absolute); + let mut buf = Vec::new(); + fmt.format( + &frame(0, ts(1, 0), 0x123, &[0xDE, 0xAD, 0xBE, 0xEF]), + &mut buf, + ); + assert_eq!( + String::from_utf8(buf).unwrap(), + "(1.000000) can0 123 [4] DE AD BE EF\n" + ); + } + + #[test] + fn console_format_extended_empty_frame() { + let mut fmt = + CanutilsConsoleFormatter::new(vec!["can0".to_string()], TimestampMode::Absolute); + let mut buf = Vec::new(); + fmt.format( + &frame(0, ts(1, 0), 0x18FECA00 | libc::CAN_EFF_FLAG, &[]), + &mut buf, + ); + assert_eq!( + String::from_utf8(buf).unwrap(), + "(1.000000) can0 18FECA00 [0]\n" + ); + } + + #[test] + fn console_format_error_frame_decodes_to_human_text() { + let mut fmt = + CanutilsConsoleFormatter::new(vec!["can0".to_string()], TimestampMode::Absolute); + let mut buf = Vec::new(); + fmt.format( + &frame(0, ts(1, 0), libc::CAN_ERR_FLAG | 0x40, &[0; 8]), + &mut buf, + ); + assert_eq!( + String::from_utf8(buf).unwrap(), + "(1.000000) can0 20000040 [8] 00 00 00 00 00 00 00 00 ERRORFRAME\n\tbus-off\n" + ); + } + + #[test] + fn console_format_delta_timestamps() { + let mut fmt = CanutilsConsoleFormatter::new(vec!["can0".to_string()], TimestampMode::Delta); + let mut buf = Vec::new(); + fmt.format(&frame(0, ts(100, 0), 0x123, &[0xAB]), &mut buf); + fmt.format(&frame(0, ts(100, 250_000_000), 0x123, &[0xAB]), &mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + "(000.000000) can0 123 [1] AB\n(000.250000) can0 123 [1] AB\n" + ); + } } From beb21ea19169df3f3df14426cd134cd7fcaa3b7c Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sat, 6 Jun 2026 10:18:08 -0500 Subject: [PATCH 5/6] Make Pipeline non-generic This allows picking the Formatter trait object at runtime, which is necessary to support a --format CLI option --- candumpr/src/main.rs | 5 ++++- candumpr/src/pipeline.rs | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/candumpr/src/main.rs b/candumpr/src/main.rs index 8e8597e..0502fc9 100644 --- a/candumpr/src/main.rs +++ b/candumpr/src/main.rs @@ -157,7 +157,10 @@ fn main() -> ExitCode { // Last logged bus state per sock_id, so we log only transitions. let mut bus_state: Vec = vec![BusState::default(); names.len()]; - let formatter = CanutilsFileFormatter::new(cli.interfaces, TimestampMode::Absolute); + let formatter: Box = Box::new(CanutilsFileFormatter::new( + cli.interfaces, + TimestampMode::Absolute, + )); let header = formatter.header().map(|h| h.to_vec()); let sink = Sink::new( StdoutWriter::new(), diff --git a/candumpr/src/pipeline.rs b/candumpr/src/pipeline.rs index 42d4699..c7a45ed 100644 --- a/candumpr/src/pipeline.rs +++ b/candumpr/src/pipeline.rs @@ -4,18 +4,18 @@ use crate::recv::Timestamp; use crate::sink::Sink; /// Orchestrates formatting batches of [CanFrame]s and then writing them to each [Sink] -pub struct Pipeline { - formatter: F, +pub struct Pipeline { + formatter: Box, pub(crate) sinks: Vec, bufs: Vec>, first_ts: Vec>, } -impl Pipeline { +impl Pipeline { /// Construct a Pipeline over a non-empty set of sinks. /// /// There should either be one sink, or a sink for every CAN interface being logged. - pub fn new(formatter: F, sinks: Vec) -> Self { + pub fn new(formatter: Box, sinks: Vec) -> Self { assert!(!sinks.is_empty(), "Pipeline requires at least one Sink"); let n = sinks.len(); let bufs = (0..n).map(|_| Vec::with_capacity(4096)).collect(); @@ -157,7 +157,10 @@ mod tests { "can3".to_string(), ]; let mut pipeline = Pipeline::new( - CanutilsFileFormatter::new(names.clone(), TimestampMode::Absolute), + Box::new(CanutilsFileFormatter::new( + names.clone(), + TimestampMode::Absolute, + )), vec![sink()], ); @@ -173,7 +176,10 @@ mod tests { let names = vec!["can0".to_string(), "can1".to_string(), "can2".to_string()]; let sinks = vec![sink(), sink(), sink()]; let mut pipeline = Pipeline::new( - CanutilsFileFormatter::new(names.clone(), TimestampMode::Absolute), + Box::new(CanutilsFileFormatter::new( + names.clone(), + TimestampMode::Absolute, + )), sinks, ); From 0fd849452eb7524a0f86bad7b3d157c641e26a03 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Sat, 6 Jun 2026 10:25:32 -0500 Subject: [PATCH 6/6] Add --format and --timestamp options to candumpr --- candumpr/src/main.rs | 29 +++++++++++++++++---- candumpr/tests/format.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 candumpr/tests/format.rs diff --git a/candumpr/src/main.rs b/candumpr/src/main.rs index 0502fc9..e19af5b 100644 --- a/candumpr/src/main.rs +++ b/candumpr/src/main.rs @@ -5,7 +5,7 @@ use std::time::Duration; use candumpr::can; use candumpr::errframe::{BusState, ErrorFrame}; -use candumpr::format::{CanutilsFileFormatter, Formatter, TimestampMode}; +use candumpr::format::{CanutilsConsoleFormatter, CanutilsFileFormatter, Formatter, TimestampMode}; use candumpr::frame::CanFrame; use candumpr::pipeline::Pipeline; use candumpr::recv::netlink::{self, LinkEvent}; @@ -68,6 +68,15 @@ fn log_error_frames(batch: &[CanFrame], bus_state: &mut [BusState], names: &[Str } } +/// Output format for received frames. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum Format { + /// can-utils candump file format: `(ts) iface ID#DATA`. + CandumpFile, + /// can-utils candump console format: `(ts) iface ID [len] B0 B1 ...`. + CandumpConsole, +} + /// Log CAN traffic from multiple networks. #[derive(Parser)] #[command(version)] @@ -76,6 +85,14 @@ struct Cli { #[arg(required = true)] interfaces: Vec, + /// Output format for received frames. + #[arg(long, value_enum, default_value = "candump-file")] + format: Format, + + /// Timestamp rendering mode. Only applies to the candump formats. + #[arg(long, value_enum, default_value = "absolute")] + timestamp: TimestampMode, + /// Log level for tracing output on stderr. #[arg(long, default_value = "INFO")] log_level: tracing::Level, @@ -157,10 +174,12 @@ fn main() -> ExitCode { // Last logged bus state per sock_id, so we log only transitions. let mut bus_state: Vec = vec![BusState::default(); names.len()]; - let formatter: Box = Box::new(CanutilsFileFormatter::new( - cli.interfaces, - TimestampMode::Absolute, - )); + let formatter: Box = match cli.format { + Format::CandumpFile => Box::new(CanutilsFileFormatter::new(cli.interfaces, cli.timestamp)), + Format::CandumpConsole => { + Box::new(CanutilsConsoleFormatter::new(cli.interfaces, cli.timestamp)) + } + }; let header = formatter.header().map(|h| h.to_vec()); let sink = Sink::new( StdoutWriter::new(), diff --git a/candumpr/tests/format.rs b/candumpr/tests/format.rs new file mode 100644 index 0000000..d21455c --- /dev/null +++ b/candumpr/tests/format.rs @@ -0,0 +1,55 @@ +use std::os::unix::io::AsFd; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use candumpr::can::{self, LinuxCanFrame}; +use pretty_assertions::assert_eq; +use vcan_fixture::VcanHarness; + +#[ctor::ctor] +fn setup() { + tracing_subscriber::fmt().with_test_writer().init(); + vcan_fixture::enter_namespace(); +} + +fn run_and_log_one_frame(extra_args: &[&str]) -> (String, Vec) { + let vcans = VcanHarness::new(1).unwrap(); + let iface = vcans.names()[0].clone(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_candumpr")); + cmd.arg("--log-level=TRACE"); + for arg in extra_args { + cmd.arg(arg); + } + let child = cmd + .arg(&iface) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + std::thread::sleep(Duration::from_millis(200)); + + let tx = can::open_can_raw_blocking(&iface).unwrap(); + can::send_frame(tx.as_fd(), &LinuxCanFrame::new(0x123, &[0xAB])).unwrap(); + + std::thread::sleep(Duration::from_millis(300)); + unsafe { + libc::kill(child.id() as libc::pid_t, libc::SIGINT); + } + let output = child.wait_with_output().unwrap(); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8(output.stdout).unwrap(); + print!("{stdout}"); + let lines = stdout.lines().map(str::to_string).collect(); + (iface, lines) +} + +#[test] +#[cfg_attr(feature = "ci", ignore = "requires vcan")] +fn console_format_with_zero_timestamp_is_deterministic() { + let (iface, lines) = + run_and_log_one_frame(&["--format", "candump-console", "--timestamp", "zero"]); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0], format!("(000.000000) {iface} 123 [1] AB")); +}