use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;
use crate::engine::format::InputFormat;
use crate::engine::{
CANBOAT_BEM, DecodedPgn, FramePacketType, PacketType, Reassembled, Reassembler,
};
use crate::io::{FrameReader, LineFrameReader};
#[derive(Debug, Default, Clone, Copy)]
pub struct Config<'a> {
pub forced_format: Option<InputFormat>,
pub pgn_filter: Option<u32>,
pub src_filter: Option<u8>,
pub dst_filter: Option<u8>,
pub suppress_startup_record: bool,
pub units: crate::engine::Units,
pub protocol: crate::engine::BusProtocol,
pub fixed_time: Option<&'a str>,
pub ignore_checksum: bool,
}
pub fn decode_file<F: FnMut(&DecodedPgn)>(
path: &Path,
cfg: &Config<'_>,
sink: F,
) -> io::Result<()> {
if crate::io::container::is_container(path) {
let reader = crate::io::container::plain_reader(path, Default::default())
.map_err(|e| open_error(path, e))?;
return decode_stream(reader, cfg, sink);
}
let file = File::open(path).map_err(|e| open_error(path, e))?;
decode_stream(BufReader::new(file), cfg, sink)
}
fn open_error(path: &Path, e: io::Error) -> io::Error {
io::Error::new(e.kind(), format!("opening {}: {e}", path.display()))
}
#[cfg(not(target_arch = "wasm32"))]
fn now_iso_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let (secs, millis) = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| (d.as_secs() as i64, d.subsec_millis()))
.unwrap_or((0, 0));
let days = secs.div_euclid(86_400);
let day_secs = secs.rem_euclid(86_400) as u32;
let (y, mo, d) = crate::engine::format::days_to_ymd(days);
let (h, m, s) = (day_secs / 3600, (day_secs / 60) % 60, day_secs % 60);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.{millis:03}Z")
}
#[cfg(target_arch = "wasm32")]
fn now_iso_timestamp() -> String {
"1970-01-01T00:00:00.000Z".to_string()
}
pub fn decode_stream<R: BufRead, F: FnMut(&DecodedPgn)>(
source: R,
cfg: &Config<'_>,
mut sink: F,
) -> io::Result<()> {
let db = cfg.protocol.database(cfg.units);
let mut reader = match cfg.forced_format {
Some(fmt) => LineFrameReader::with_format(source, fmt),
None => LineFrameReader::new(source),
}
.protocol(cfg.protocol)
.ignore_checksum(cfg.ignore_checksum);
let mut coalesced_mode = false;
let mut warned_mislabeled = false;
let mut reasm = Reassembler::new();
while let Some(mut frame) = reader.read_frame()? {
coalesced_mode |= reader.header_coalesced();
if frame.timestamp.is_none() {
frame.timestamp = Some(match cfg.fixed_time {
Some(s) => s.to_string(),
None => now_iso_timestamp(),
});
}
if coalesced_mode && !warned_mislabeled && frame.data.len() <= 8 {
let mut variants = db.pgn_variants(frame.pgn).peekable();
let all_fast_over_8 = variants.peek().is_some()
&& variants.all(|p| {
matches!(p.packet_type, PacketType::Fast)
&& p.min_length.or(p.length).is_some_and(|n| n > 8)
});
if all_fast_over_8 {
log::warn!(
"input declared a coalesced stream (# format=FAST) but carries an \
8-byte frame of fast-packet PGN {} (whose shortest complete message \
exceeds 8 bytes); this looks like single frames mislabeled as \
coalesced — fast-packet PGNs will decode incompletely. Re-emit the \
source with `# format=FRAMES` so the reassembler runs.",
frame.pgn
);
warned_mislabeled = true;
}
}
if cfg.suppress_startup_record && frame.pgn == CANBOAT_BEM {
continue;
}
if let Some(want) = cfg.pgn_filter
&& frame.pgn != want
{
continue;
}
if let Some(want) = cfg.src_filter
&& frame.src != want
{
continue;
}
if let Some(want) = cfg.dst_filter
&& frame.dst != want
{
continue;
}
if frame.data.len() > 8 && reader.active_format() != Some(InputFormat::PlainMixFast) {
coalesced_mode = true;
}
let packet_type = if coalesced_mode {
FramePacketType::Other
} else {
db.first_pgn(frame.pgn)
.or_else(|| db.fallback_pgn(frame.pgn))
.map(|p| match p.packet_type {
PacketType::Fast => FramePacketType::Fast,
PacketType::Single => FramePacketType::Single,
_ => FramePacketType::Other,
})
.unwrap_or(FramePacketType::Other)
};
let assembled = match reasm.push(frame, packet_type) {
Reassembled::PassThrough(f) | Reassembled::Complete(f) => f,
Reassembled::Partial => continue,
Reassembled::Error(e) => {
log::warn!("reassembly error: {e}");
continue;
}
};
let decoded = match db.decode(&assembled) {
Ok(d) => d,
Err(e) => {
log::warn!("decode error: {e}");
continue;
}
};
sink(&decoded);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const MIXED: &str = "\
2026-05-29T19:16:04.826Z,2,127251,14,255,8,ff,5e,7d,00,00,ff,ff,ff
2026-05-29T19:16:04.926Z,2,127250,35,255,8,ff,5e,7d,00,00,ff,ff,ff
2026-05-29T19:16:05.026Z,2,127251,35,255,8,ff,60,7d,00,00,ff,ff,ff
2026-05-29T19:16:05.126Z,3,127245,14,12,8,ff,f8,ff,7f,ff,7f,ff,ff
";
fn run(input: &str, cfg: &Config<'_>) -> Vec<(u32, u8, u8)> {
let mut out = Vec::new();
decode_stream(input.as_bytes(), cfg, |d| out.push((d.pgn, d.src, d.dst)))
.expect("stream decodes");
out
}
#[test]
fn no_filters_decodes_every_line() {
let got = run(MIXED, &Config::default());
assert_eq!(
got,
vec![
(127251, 14, 255),
(127250, 35, 255),
(127251, 35, 255),
(127245, 14, 12),
]
);
}
#[test]
fn pgn_filter_keeps_only_that_pgn() {
let cfg = Config {
pgn_filter: Some(127251),
..Default::default()
};
let got = run(MIXED, &cfg);
assert_eq!(got, vec![(127251, 14, 255), (127251, 35, 255)]);
}
#[test]
fn src_filter_keeps_only_that_source() {
let cfg = Config {
src_filter: Some(35),
..Default::default()
};
let got = run(MIXED, &cfg);
assert_eq!(got, vec![(127250, 35, 255), (127251, 35, 255)]);
}
#[test]
fn dst_filter_keeps_only_that_destination() {
let cfg = Config {
dst_filter: Some(12),
..Default::default()
};
let got = run(MIXED, &cfg);
assert_eq!(got, vec![(127245, 14, 12)]);
}
#[test]
fn filters_combine() {
let cfg = Config {
pgn_filter: Some(127251),
src_filter: Some(35),
..Default::default()
};
let got = run(MIXED, &cfg);
assert_eq!(got, vec![(127251, 35, 255)]);
}
#[test]
fn filter_matching_nothing_yields_nothing() {
let cfg = Config {
src_filter: Some(200),
..Default::default()
};
assert!(run(MIXED, &cfg).is_empty());
}
fn plain_line(f: &crate::engine::RawFrame) -> String {
let hex: Vec<String> = f.data.iter().map(|b| format!("{b:02x}")).collect();
format!(
"{},{},{},{},{},{},{}\n",
f.timestamp.as_deref().unwrap_or(""),
f.prio,
f.pgn,
f.src,
f.dst,
f.data.len(),
hex.join(",")
)
}
#[test]
fn startup_record_is_suppressed_on_request() {
let rec = crate::engine::startup::startup_record_at(
"8.1.0",
"canboat-test",
"/dev/null",
"2026-05-29T19:16:04.800Z".to_string(),
);
let input = format!("{}{MIXED}", plain_line(&rec));
let kept = run(&input, &Config::default());
assert_eq!(
kept.iter().filter(|(p, ..)| *p == CANBOAT_BEM).count(),
1,
"kept by default"
);
let cfg = Config {
suppress_startup_record: true,
..Default::default()
};
let dropped = run(&input, &cfg);
assert!(
!dropped.iter().any(|(p, ..)| *p == CANBOAT_BEM),
"suppressed on request"
);
assert_eq!(dropped.len(), kept.len() - 1, "only that record goes");
}
#[test]
fn timeless_input_gets_the_fixed_stamp() {
let cfg = Config {
fixed_time: Some("2026-01-01T00:00:00.000Z"),
..Default::default()
};
let mut stamps = Vec::new();
decode_stream(
b" can0 09F80115 [8] FF 5E 7D 00 00 FF FF FF\n".as_slice(),
&cfg,
|d| stamps.push(d.timestamp.clone()),
)
.expect("stream decodes");
assert_eq!(stamps, vec![Some("2026-01-01T00:00:00.000Z".to_string())]);
}
#[test]
fn fixed_stamp_does_not_override_a_real_one() {
let cfg = Config {
fixed_time: Some("2026-01-01T00:00:00.000Z"),
..Default::default()
};
let mut stamps = Vec::new();
decode_stream(MIXED.as_bytes(), &cfg, |d| stamps.push(d.timestamp.clone())).expect("ok");
assert_eq!(stamps[0].as_deref(), Some("2026-05-29T19:16:04.826Z"));
}
#[test]
fn timeless_input_falls_back_to_the_host_clock() {
let mut stamps = Vec::new();
decode_stream(
b" can0 09F80115 [8] FF 5E 7D 00 00 FF FF FF\n".as_slice(),
&Config::default(),
|d| stamps.push(d.timestamp.clone()),
)
.expect("stream decodes");
let ts = stamps[0].clone().expect("stamped");
assert_eq!(ts.len(), 24, "YYYY-MM-DDTHH:MM:SS.mmmZ, got {ts}");
assert!(ts.ends_with('Z'));
}
#[test]
fn fast_packet_fragments_reassemble_into_one_record() {
let input = "\
2026-05-29T19:16:04.000Z,3,129029,14,255,8,40,2b,f9,5b,f4,4c,90,42
2026-05-29T19:16:04.010Z,3,129029,14,255,8,41,32,00,00,00,00,00,00
2026-05-29T19:16:04.020Z,3,129029,14,255,8,42,00,00,00,00,00,00,00
2026-05-29T19:16:04.030Z,3,129029,14,255,8,43,00,00,00,00,00,00,00
2026-05-29T19:16:04.040Z,3,129029,14,255,8,44,00,00,00,00,00,00,00
2026-05-29T19:16:04.050Z,3,129029,14,255,8,45,00,00,00,00,00,00,00
2026-05-29T19:16:04.060Z,3,129029,14,255,8,46,00,00,ff,ff,ff,ff,ff
";
let mut lens = Vec::new();
decode_stream(input.as_bytes(), &Config::default(), |d| {
lens.push(d.data.len())
})
.expect("stream decodes");
assert_eq!(lens.len(), 1, "seven fragments make one record");
assert_eq!(lens[0], 43, "the whole fast-packet payload");
}
#[test]
fn truncated_fast_packet_emits_nothing() {
let input = "\
2026-05-29T19:16:04.000Z,3,129029,14,255,8,40,2b,f9,5b,f4,4c,90,42
2026-05-29T19:16:04.010Z,3,129029,14,255,8,41,32,00,00,00,00,00,00
";
assert!(run(input, &Config::default()).is_empty());
}
#[test]
fn oversized_line_switches_to_coalesced_mode() {
let input = "2026-05-29T19:16:04.000Z,3,129029,14,255,43,2b,f9,5b,f4,4c,90,42,32,\
00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,\
00,00,00,ff,ff\n";
let mut lens = Vec::new();
decode_stream(input.as_bytes(), &Config::default(), |d| {
lens.push(d.data.len())
})
.expect("stream decodes");
assert_eq!(lens, vec![43]);
}
#[test]
fn forced_format_overrides_detection() {
let cfg = Config {
forced_format: Some(InputFormat::Ydwg02),
..Default::default()
};
assert!(
run(MIXED, &cfg).is_empty(),
"PLAIN lines are not valid YDWG-02"
);
}
#[test]
fn j1939_flag_selects_the_other_table() {
let line = "2026-05-29T19:16:04.826Z,3,127251,14,255,8,ff,5e,7d,00,00,ff,ff,ff\n";
let mut n2k = None;
decode_stream(line.as_bytes(), &Config::default(), |d| {
n2k = Some(d.description)
})
.expect("ok");
let cfg = Config {
protocol: crate::engine::BusProtocol::J1939,
..Default::default()
};
let mut j1939 = None;
decode_stream(line.as_bytes(), &cfg, |d| j1939 = Some(d.description)).expect("ok");
assert_eq!(n2k, Some("Rate of Turn"));
assert!(j1939.is_some(), "the J1939 table must decode the frame");
assert_ne!(n2k, j1939, "the J1939 table is a different schema");
}
#[test]
fn a_bad_line_does_not_stop_the_stream() {
let input = "\
2026-05-29T19:16:04.826Z,2,127251,14,255,8,ff,5e,7d,00,00,ff,ff,ff
this is not a canboat line at all
2026-05-29T19:16:05.026Z,2,127251,35,255,8,ff,60,7d,00,00,ff,ff,ff
";
let got = run(input, &Config::default());
assert_eq!(got, vec![(127251, 14, 255), (127251, 35, 255)]);
}
#[test]
fn empty_input_is_clean() {
assert!(run("", &Config::default()).is_empty());
}
#[test]
fn missing_file_error_names_the_path() {
let err = decode_file(
Path::new("/nonexistent/nope.log"),
&Config::default(),
|_| {},
)
.expect_err("must fail");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(err.to_string().contains("nope.log"), "got {err}");
}
}