use std::time::{SystemTime, UNIX_EPOCH};
use crate::engine::format::timestamp::{days_since_epoch, days_to_ymd};
use crate::engine::frame::RawFrame;
pub const CANBOAT_BEM: u32 = 0x40200;
pub fn startup_record(version: &str, source: &str, device: &str) -> RawFrame {
startup_record_at(version, source, device, now_iso())
}
pub fn startup_record_at(version: &str, source: &str, device: &str, timestamp: String) -> RawFrame {
let mut parts = version.split('.');
let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let patch: u32 = parts
.next()
.map(|s| s.split(|c: char| !c.is_ascii_digit()).next().unwrap_or(""))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let ver = (major * 1000 + minor * 100 + patch) as u16;
let mut data = [0u8; 66];
data[0] = (ver & 0xff) as u8;
data[1] = (ver >> 8) as u8;
copy_fixed(&mut data[2..34], source);
copy_fixed(&mut data[34..66], device);
RawFrame::new(Some(timestamp), 7, CANBOAT_BEM, 0, 255, data)
}
fn copy_fixed(dst: &mut [u8], s: &str) {
let n = s.len().min(dst.len() - 1);
dst[..n].copy_from_slice(&s.as_bytes()[..n]);
}
fn now_iso() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format_iso_ms(now.as_millis() as u64)
}
pub fn format_iso_ms(ms: u64) -> String {
let secs = (ms / 1000) as i64;
let millis = ms % 1000;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (y, mo, d) = days_to_ymd(days);
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
}
pub fn parse_iso_ms(s: &str) -> Option<i64> {
let s = s.trim_end_matches('Z');
let (date, time) = s
.split_once('T')
.or_else(|| s.split_once(' '))
.or_else(|| {
s.match_indices('-')
.nth(2)
.map(|(i, _)| (&s[..i], &s[i + 1..]))
})?;
let mut date_parts = date.split('-');
let y: i32 = date_parts.next()?.parse().ok()?;
let mo: u32 = date_parts.next()?.parse().ok()?;
let d: u32 = date_parts.next()?.parse().ok()?;
if date_parts.next().is_some() {
return None;
}
let (hms, millis) = match time.split_once('.') {
Some((hms, frac)) => {
let mut ms = 0u32;
for (i, ch) in frac.chars().take(3).enumerate() {
let d = ch.to_digit(10)?;
ms += d * 10u32.pow(2 - i as u32);
}
(hms, ms as i64)
}
None => (time, 0),
};
let mut hms_parts = hms.split(':');
let h: i64 = hms_parts.next()?.parse().ok()?;
let mi: i64 = hms_parts.next()?.parse().ok()?;
let s: i64 = hms_parts.next()?.parse().ok()?;
if hms_parts.next().is_some() {
return None;
}
let days = days_since_epoch(i64::from(y), i64::from(mo), i64::from(d))?;
Some((days * 86_400 + h * 3600 + mi * 60 + s) * 1000 + millis)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_iso_ms_round_trips_format_iso_ms() {
for ms in [
0i64,
1,
1_000,
1_500,
86_400_000,
1_735_689_600_000, 1_782_115_200_000, ] {
let s = format_iso_ms(ms as u64);
assert_eq!(parse_iso_ms(&s), Some(ms), "roundtrip failed for ms={ms}");
}
}
#[test]
fn parse_iso_ms_accepts_optional_zulu_and_fractional() {
assert_eq!(
parse_iso_ms("2026-01-01T00:00:00.500Z"),
Some(1_767_225_600_500)
);
assert_eq!(
parse_iso_ms("2026-01-01T00:00:00.500"),
Some(1_767_225_600_500)
);
assert_eq!(parse_iso_ms("2026-01-01T00:00:00"), Some(1_767_225_600_000));
assert_eq!(
parse_iso_ms("2026-01-01 00:00:00.500Z"),
Some(1_767_225_600_500)
);
assert_eq!(
parse_iso_ms("2026-01-01T00:00:00.5"),
Some(1_767_225_600_500)
);
}
#[test]
fn parse_iso_ms_accepts_canboat_dash_form() {
assert_eq!(
parse_iso_ms("2026-01-01-00:00:00.500"),
parse_iso_ms("2026-01-01T00:00:00.500")
);
assert_eq!(
parse_iso_ms("2026-07-02-10:43:23.516"),
parse_iso_ms("2026-07-02T10:43:23.516")
);
assert!(parse_iso_ms("2026-07-02-10:43:23.516").is_some());
}
#[test]
fn parse_iso_ms_rejects_malformed_input() {
assert_eq!(parse_iso_ms(""), None);
assert_eq!(parse_iso_ms("not-a-time"), None);
assert_eq!(parse_iso_ms("2026-13-01T00:00:00Z"), None); assert_eq!(parse_iso_ms("2026-01-32T00:00:00Z"), None); assert_eq!(parse_iso_ms("2026-01-01T00:00:00:00Z"), None); }
#[test]
fn encodes_version_and_strings() {
let f = startup_record_at(
"6.2.0",
"socketcan-serial",
"nmea2000",
"2026-01-01T00:00:00.000Z".into(),
);
assert_eq!(f.pgn, CANBOAT_BEM);
assert_eq!(f.prio, 7);
assert_eq!(f.dst, 255);
assert_eq!(f.data.len(), 66);
assert_eq!(f.data[0], 0x38);
assert_eq!(f.data[1], 0x18);
assert_eq!(&f.data[2..18], b"socketcan-serial");
assert_eq!(&f.data[34..42], b"nmea2000");
}
}