use std::borrow::Cow;
use std::fmt::Write;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use indexmap::IndexMap;
use crate::engine::startup::format_iso_ms;
pub const SENSOR_TTL: Duration = Duration::from_secs(120);
pub const AIS_TTL: Duration = Duration::from_secs(3600);
pub const DEVICE_CLAIM_TTL: Duration = Duration::from_secs(3600);
pub const DEVICE_INFO_PGNS: &[u32] = &[126996, 126998];
pub const ISO_ADDRESS_CLAIM_PGN: u32 = 60928;
pub fn ttl_for_pgn(pgn: u32, is_ais: bool) -> Option<Duration> {
if DEVICE_INFO_PGNS.contains(&pgn) {
None
} else if pgn == ISO_ADDRESS_CLAIM_PGN {
Some(DEVICE_CLAIM_TTL)
} else if is_ais {
Some(AIS_TTL)
} else {
Some(SENSOR_TTL)
}
}
pub const AIS_PGNS: &[u32] = &[
129038, 129039, 129040, 129041, 129793, 129794, 129795, 129796, 129797, 129798, 129801, 129802,
129803, 129804, 129805, 129806, 129807, 129808, 129809, 129810,
];
#[inline]
pub fn is_ais_pgn(pgn: u32) -> bool {
AIS_PGNS.binary_search(&pgn).is_ok()
}
const PRIMARY_KEY_OVERRIDES: &[(u32, &str)] = &[];
#[inline]
pub fn is_part_of_primary_key(pgn: u32, field: &crate::engine::FieldInfo) -> bool {
if field.part_of_primary_key == Some(true) {
return true;
}
PRIMARY_KEY_OVERRIDES
.iter()
.any(|&(p, id)| p == pgn && id == field.id)
}
pub fn composite_secondary<F>(info: &crate::engine::PgnInfo, mut get_text: F) -> Option<String>
where
F: FnMut(&crate::engine::FieldInfo) -> Option<String>,
{
let mut parts: Vec<String> = Vec::new();
for f in info.fields {
if !is_part_of_primary_key(info.pgn, f) {
continue;
}
if field_in_any_repeat_set(info, f).is_some() {
continue;
}
if let Some(s) = get_text(f) {
parts.push(s);
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("_"))
}
}
pub fn repeating_pk_set(info: &crate::engine::PgnInfo) -> Option<u8> {
for f in info.fields {
if !is_part_of_primary_key(info.pgn, f) {
continue;
}
if let Some(set) = field_in_any_repeat_set(info, f) {
return Some(set);
}
}
None
}
pub fn per_iteration_secondary<F, G>(
info: &crate::engine::PgnInfo,
repeat_set: u8,
iter: u32,
mut get_top: F,
mut get_iter: G,
) -> Option<String>
where
F: FnMut(&crate::engine::FieldInfo) -> Option<String>,
G: FnMut(&crate::engine::FieldInfo, u32) -> Option<String>,
{
let mut parts: Vec<String> = Vec::new();
for f in info.fields {
if !is_part_of_primary_key(info.pgn, f) {
continue;
}
let resolved = match field_in_any_repeat_set(info, f) {
None => get_top(f),
Some(set) if set == repeat_set => get_iter(f, iter),
Some(_) => None,
};
if let Some(s) = resolved {
parts.push(s);
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("_"))
}
}
fn field_in_any_repeat_set(
pgn: &crate::engine::PgnInfo,
field: &crate::engine::FieldInfo,
) -> Option<u8> {
let order = field.order as u32;
if let (Some(start), Some(size)) = (
pgn.repeating_field_set1_start_field,
pgn.repeating_field_set1_size,
) && order >= start
&& order < start + size
{
return Some(1);
}
if let (Some(start), Some(size)) = (
pgn.repeating_field_set2_start_field,
pgn.repeating_field_set2_size,
) && order >= start
&& order < start + size
{
return Some(2);
}
None
}
pub fn classify_json_line<F>(line: &str, mut sink: F) -> usize
where
F: FnMut(SnapshotInput),
{
let Some(pgn) = crate::engine::analyzer_json::int(line, "pgn") else {
return 0;
};
let pgn = pgn as u32;
let Some(src) = crate::engine::analyzer_json::int(line, "src") else {
return 0;
};
let src = src as u8;
let description = crate::engine::analyzer_json::value(line, "description")
.unwrap_or("")
.to_string();
let is_ais = is_ais_pgn(pgn);
let info = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric).first_pgn(pgn);
let use_camel = crate::engine::analyzer_json::camel_wrapper_id(line).is_some()
|| info.is_some_and(|i| crate::engine::analyzer_json::uses_camel_keys(line, i.fields));
let field_key = move |f: &crate::engine::FieldInfo| if use_camel { f.id } else { f.name };
let repeat_set = info.and_then(repeating_pk_set);
let Some(rs) = repeat_set else {
let secondary = info.and_then(|info| {
composite_secondary(info, |f| {
crate::engine::analyzer_json::lookup_text(line, field_key(f)).map(|s| s.to_string())
})
});
sink(SnapshotInput {
pgn,
src,
secondary,
is_ais,
pgn_description: description,
line: line.to_string(),
});
return 1;
};
let list_key = if rs == 1 { "list" } else { "list2" };
let Some((arr_start, arr_end)) = crate::engine::analyzer_json::array_span(line, list_key)
else {
let secondary = info.and_then(|info| {
composite_secondary(info, |f| {
crate::engine::analyzer_json::lookup_text(line, field_key(f)).map(|s| s.to_string())
})
});
sink(SnapshotInput {
pgn,
src,
secondary,
is_ais,
pgn_description: description,
line: line.to_string(),
});
return 1;
};
let arr = &line[arr_start..arr_end];
let elements = crate::engine::analyzer_json::split_object_array(arr);
let pgn_info = info.expect("repeat_set source implies PgnInfo present");
let mut emitted = 0usize;
for (iter, elem) in elements.iter().enumerate() {
let secondary = per_iteration_secondary(
pgn_info,
rs,
iter as u32,
|f| {
crate::engine::analyzer_json::lookup_text(line, field_key(f)).map(|s| s.to_string())
},
|f, _| {
crate::engine::analyzer_json::lookup_text(elem, field_key(f)).map(|s| s.to_string())
},
);
let mut spliced = String::with_capacity(line.len() - arr.len() + elem.len() + 2);
spliced.push_str(&line[..arr_start]);
spliced.push('[');
spliced.push_str(elem);
spliced.push(']');
spliced.push_str(&line[arr_end..]);
sink(SnapshotInput {
pgn,
src,
secondary,
is_ais,
pgn_description: description.clone(),
line: spliced,
});
emitted += 1;
}
emitted
}
#[derive(Debug, Clone)]
pub struct SnapshotInput {
pub pgn: u32,
pub src: u8,
pub secondary: Option<String>,
pub is_ais: bool,
pub pgn_description: String,
pub line: String,
}
enum LinePayload {
Ready(String),
Lazy(Box<dyn Fn() -> String + Send>),
}
impl LinePayload {
fn render(&self) -> Cow<'_, str> {
match self {
LinePayload::Ready(s) => Cow::Borrowed(s),
LinePayload::Lazy(f) => Cow::Owned(f()),
}
}
}
struct CacheEntry {
payload: LinePayload,
pgn_short_description: String,
expires_at: Option<Instant>,
last_timestamp: Option<u64>,
count: u64,
previous_store_at: Option<Instant>,
interval_ms: u64,
}
type CacheKey = (u32, u8, Option<String>);
pub struct SnapshotStore {
cache: Mutex<IndexMap<CacheKey, CacheEntry>>,
epoch_instant: Instant,
epoch_wall_ms: u64,
}
impl SnapshotStore {
pub fn new() -> Self {
let epoch_wall_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
Self {
cache: Mutex::new(IndexMap::new()),
epoch_instant: Instant::now(),
epoch_wall_ms,
}
}
pub fn store(&self, input: SnapshotInput) {
self.store_entry(
input.pgn,
input.src,
input.secondary,
input.is_ais,
&input.pgn_description,
LinePayload::Ready(input.line),
Instant::now(),
);
}
#[allow(clippy::too_many_arguments)]
pub fn store_lazy(
&self,
pgn: u32,
src: u8,
secondary: Option<String>,
is_ais: bool,
pgn_description: &str,
render: Box<dyn Fn() -> String + Send>,
now: Instant,
) {
self.store_entry(
pgn,
src,
secondary,
is_ais,
pgn_description,
LinePayload::Lazy(render),
now,
);
}
#[allow(clippy::too_many_arguments)]
fn store_entry(
&self,
pgn: u32,
src: u8,
secondary: Option<String>,
is_ais: bool,
pgn_description: &str,
payload: LinePayload,
now: Instant,
) {
let expires_at = ttl_for_pgn(pgn, is_ais).map(|ttl| now + ttl);
let key = (pgn, src, secondary);
let mut guard = self.cache.lock().expect("snapshot cache poisoned");
let prev_count = guard.get(&key).map(|e| e.count).unwrap_or(0);
let prev_store_at = guard.get(&key).and_then(|e| e.previous_store_at);
let interval_ms = match prev_store_at {
Some(t) => now.saturating_duration_since(t).as_millis() as u64,
None => 0,
};
let wall_ms = self.epoch_wall_ms.saturating_add(
now.saturating_duration_since(self.epoch_instant)
.as_millis() as u64,
);
let entry = CacheEntry {
payload,
pgn_short_description: short_description(pgn_description),
expires_at,
last_timestamp: Some(wall_ms),
count: prev_count + 1,
previous_store_at: Some(now),
interval_ms,
};
guard.insert(key, entry);
}
pub fn snapshot(&self) -> String {
self.filtered_snapshot(|pgn, entry| {
*pgn == 129026 || *pgn == 129029 || !entry.pgn_short_description.starts_with("AIS")
})
}
pub fn ais_snapshot(&self) -> String {
self.filtered_snapshot(|pgn, entry| {
*pgn == 129026 || *pgn == 129029 || entry.pgn_short_description.starts_with("AIS")
})
}
fn filtered_snapshot<F>(&self, keep: F) -> String
where
F: Fn(&u32, &CacheEntry) -> bool,
{
let now = Instant::now();
let mut guard = self.cache.lock().expect("snapshot cache poisoned");
guard.retain(|_, v| v.expires_at.is_none_or(|t| t > now));
type GroupKey<'a> = (u8, &'a Option<String>);
let mut by_pgn: IndexMap<u32, Vec<(GroupKey<'_>, &CacheEntry)>> = IndexMap::new();
for ((pgn, src, sec), entry) in guard.iter() {
if !keep(pgn, entry) {
continue;
}
by_pgn.entry(*pgn).or_default().push(((*src, sec), entry));
}
let db = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric);
let mut out = String::with_capacity(2048);
let mut first_pgn = true;
for (pgn, entries) in by_pgn.iter() {
let desc = &entries[0].1.pgn_short_description;
let rendered: Vec<Cow<'_, str>> =
entries.iter().map(|(_, e)| e.payload.render()).collect();
let camel_id = crate::engine::analyzer_json::camel_wrapper_id(&rendered[0]);
let full_desc = camel_id
.and_then(|id| db.pgn_by_id(id))
.map_or(desc.as_str(), |info| info.description);
if first_pgn {
out.push_str("{\"");
first_pgn = false;
} else {
out.push_str("\n,\"");
}
match camel_id {
Some(id) => out.push_str(id),
None => {
let _ = write!(out, "{pgn}");
}
}
out.push_str("\":\n {\"description\":");
write_json_string(&mut out, desc);
for (((src, sec), _), payload) in entries.iter().zip(rendered.iter()) {
out.push_str("\n ,\"");
let _ = write!(out, "{src}");
if let Some(s) = sec.as_deref() {
out.push('_');
out.push_str(s);
}
out.push_str("\":");
match camel_id {
Some(id) => out.push_str(&unwrap_camel_record(payload, id, full_desc)),
None => out.push_str(payload),
}
}
out.push_str("\n }");
}
if first_pgn {
out.push('\n');
} else {
out.push_str("\n}\n");
}
out
}
pub fn status_snapshot(&self) -> String {
let now = Instant::now();
let mut guard = self.cache.lock().expect("snapshot cache poisoned");
guard.retain(|_, v| v.expires_at.is_none_or(|t| t > now));
type GroupKey<'a> = (u8, &'a Option<String>);
let mut by_pgn: IndexMap<u32, Vec<(GroupKey<'_>, &CacheEntry)>> = IndexMap::new();
for ((pgn, src, sec), entry) in guard.iter() {
by_pgn.entry(*pgn).or_default().push(((*src, sec), entry));
}
let mut out = String::with_capacity(4096);
let mut first_pgn = true;
for (pgn, entries) in by_pgn.iter() {
let desc = &entries[0].1.pgn_short_description;
if first_pgn {
out.push_str("{\"");
first_pgn = false;
} else {
out.push_str("\n,\"");
}
let _ = write!(out, "{pgn}\":\n {{\"description\":");
write_json_string(&mut out, desc);
for ((src, sec), entry) in entries {
out.push_str("\n ,\"");
let _ = write!(out, "{src}");
if let Some(s) = sec.as_deref() {
out.push('_');
out.push_str(s);
}
out.push_str("\":{\"last\":");
let last = entry.last_timestamp.map(format_iso_ms).unwrap_or_default();
write_json_string(&mut out, &last);
let _ = write!(
out,
",\"interval\":{},\"count\":{}}}",
entry.interval_ms, entry.count
);
}
out.push_str("\n }");
}
if first_pgn {
out.push('\n');
} else {
out.push_str("\n}\n");
}
out
}
pub fn len(&self) -> usize {
self.cache.lock().expect("snapshot cache poisoned").len()
}
}
impl Default for SnapshotStore {
fn default() -> Self {
Self::new()
}
}
pub fn short_description(desc: &str) -> String {
match desc.find(':') {
Some(idx) => desc[..idx].to_string(),
None => desc.to_string(),
}
}
fn unwrap_camel_record(payload: &str, camel_id: &str, full_desc: &str) -> String {
let prefix = format!("{{\"{camel_id}\":");
let Some(inner) = payload.trim().strip_prefix(&prefix) else {
return payload.to_string();
};
let inner = inner.strip_suffix('}').unwrap_or(inner);
let needle = format!("\"description\":\"{camel_id}\"");
let mut desc_json = String::new();
write_json_string(&mut desc_json, full_desc);
inner.replacen(&needle, &format!("\"description\":{desc_json}"), 1)
}
fn write_json_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
_ => out.push(c),
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeating_pk_set_detects_pgn_130824() {
let info = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric)
.first_pgn(130824)
.expect("PGN 130824 present in embedded db");
assert_eq!(repeating_pk_set(info), Some(1));
}
#[test]
fn repeating_pk_set_returns_none_for_top_level_pk() {
let info = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric)
.first_pgn(127251)
.expect("PGN 127251 present in embedded db");
assert_eq!(repeating_pk_set(info), None);
let info = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric)
.first_pgn(127509)
.expect("PGN 127509 present in embedded db");
assert_eq!(repeating_pk_set(info), None);
}
#[test]
fn per_iteration_secondary_joins_top_and_iter_values() {
let info = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric)
.first_pgn(129796)
.expect("PGN 129796 present in embedded db");
let set = repeating_pk_set(info).expect("129796 has repeating PK");
let key = per_iteration_secondary(
info,
set,
2,
|f| (f.name == "Source ID").then(|| "111".to_string()),
|f, iter| (f.name == "Destination ID").then(|| format!("dst{iter}")),
);
assert_eq!(key.as_deref(), Some("111_dst2"));
}
#[test]
fn classify_json_line_emits_single_record_with_composite_secondary() {
let line = r#"{"pgn":127501,"src":17,"description":"Binary Switch Bank Status","fields":{"Instance":0}}"#;
let mut got = Vec::new();
let n = classify_json_line(line, |input| got.push(input));
assert_eq!(n, 1);
assert_eq!(got.len(), 1);
assert_eq!(got[0].pgn, 127501);
assert_eq!(got[0].src, 17);
assert_eq!(got[0].secondary.as_deref(), Some("0"));
assert!(!got[0].is_ais);
}
#[test]
fn classify_json_line_keys_camel_secondary_by_field_id() {
let line = r#"{"binarySwitchBankStatus":{"pgn":127501,"src":17,"description":"binarySwitchBankStatus","fields":{"instance":3}}}"#;
let mut got = Vec::new();
let n = classify_json_line(line, |input| got.push(input));
assert_eq!(n, 1);
assert_eq!(got[0].pgn, 127501);
assert_eq!(got[0].src, 17);
assert_eq!(got[0].secondary.as_deref(), Some("3"));
}
#[test]
fn classify_json_line_splits_repeating_pk_pgn_into_per_iteration_records() {
let line = r#"{"pgn":130824,"src":21,"description":"Manufacturer Proprietary fast-packet addressed","fields":{"Manufacturer Code":381,"Industry Code":4,"list":[{"Key":1,"Value":42},{"Key":2,"Value":99}]}}"#;
let mut got = Vec::new();
let n = classify_json_line(line, |input| got.push(input));
assert_eq!(n, 2, "expected one record per iteration, got {got:?}");
assert!(got[0].secondary.is_some());
assert!(got[1].secondary.is_some());
assert_ne!(
got[0].secondary, got[1].secondary,
"per-iteration keys must differ"
);
assert!(got[0].line.contains("\"Key\":1"));
assert!(!got[0].line.contains("\"Key\":2"));
assert!(got[1].line.contains("\"Key\":2"));
assert!(!got[1].line.contains("\"Key\":1"));
}
#[test]
fn classify_json_line_keys_pgn_126464_on_function_code() {
let tx = r#"{"pgn":126464,"src":17,"description":"PGN List","fields":{"Function Code":0,"list":[{"PGN":127251}]}}"#;
let rx = r#"{"pgn":126464,"src":17,"description":"PGN List","fields":{"Function Code":1,"list":[{"PGN":59904}]}}"#;
let mut tx_inputs = Vec::new();
classify_json_line(tx, |i| tx_inputs.push(i));
let mut rx_inputs = Vec::new();
classify_json_line(rx, |i| rx_inputs.push(i));
assert_eq!(tx_inputs.len(), 1);
assert_eq!(rx_inputs.len(), 1);
assert_eq!(tx_inputs[0].secondary.as_deref(), Some("0"));
assert_eq!(rx_inputs[0].secondary.as_deref(), Some("1"));
let store = SnapshotStore::new();
store.store(tx_inputs.remove(0));
store.store(rx_inputs.remove(0));
assert_eq!(store.len(), 2);
let dump = store.snapshot();
assert!(dump.contains("\"17_0\":"), "TX entry missing:\n{dump}");
assert!(dump.contains("\"17_1\":"), "RX entry missing:\n{dump}");
}
#[test]
fn classify_json_line_returns_zero_when_pgn_or_src_missing() {
assert_eq!(classify_json_line("{}", |_| {}), 0);
assert_eq!(classify_json_line(r#"{"pgn":127251}"#, |_| {}), 0);
assert_eq!(classify_json_line(r#"{"src":7}"#, |_| {}), 0);
}
#[test]
fn ttl_for_device_info_pgns_is_none() {
assert!(ttl_for_pgn(126996, false).is_none());
assert!(ttl_for_pgn(126998, false).is_none());
assert!(ttl_for_pgn(126996, true).is_none());
}
#[test]
fn ttl_for_iso_address_claim_is_device_claim() {
assert_eq!(ttl_for_pgn(60928, false), Some(DEVICE_CLAIM_TTL));
assert_eq!(ttl_for_pgn(60928, true), Some(DEVICE_CLAIM_TTL));
}
#[test]
fn ttl_for_ais_record_is_ais_ttl() {
assert_eq!(ttl_for_pgn(129038, true), Some(AIS_TTL));
assert_eq!(ttl_for_pgn(129039, true), Some(AIS_TTL));
}
#[test]
fn ttl_for_anything_else_is_sensor_ttl() {
assert_eq!(ttl_for_pgn(127251, false), Some(SENSOR_TTL));
assert_eq!(ttl_for_pgn(127257, false), Some(SENSOR_TTL));
}
#[test]
fn device_claim_ttl_is_3600s() {
assert_eq!(DEVICE_CLAIM_TTL.as_secs(), 3600);
}
#[test]
fn short_description_trims_at_colon() {
assert_eq!(short_description("Simnet: Device Status"), "Simnet");
assert_eq!(short_description("Navico: Unknown 1"), "Navico");
assert_eq!(short_description("Rudder"), "Rudder");
assert_eq!(short_description("ISO Address Claim"), "ISO Address Claim");
}
#[test]
fn snapshot_empty_returns_blank_line() {
let s = SnapshotStore::new();
assert_eq!(s.snapshot(), "\n");
assert_eq!(s.len(), 0);
}
#[test]
fn snapshot_wraps_one_entry_in_nested_object() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: r#"{"pgn":127251,"src":7,"fields":{"Rate":0}}"#.to_string(),
});
let dump = s.snapshot();
assert!(dump.starts_with("{\"127251\":\n {\"description\":\"Rate of Turn\""));
assert!(dump.contains("\"7\":{\"pgn\":127251,\"src\":7,\"fields\":{\"Rate\":0}}"));
assert!(dump.ends_with("}\n"));
}
#[test]
fn snapshot_camel_keys_by_id_and_unwraps_record() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 60928,
src: 51,
secondary: None,
is_ais: false,
pgn_description: "ISO Address Claim".to_string(),
line: r#"{"isoAddressClaim":{"prio":6,"src":51,"dst":255,"pgn":60928,"description":"isoAddressClaim","fields":{"uniqueNumber":1605586}}}"#.to_string(),
});
let expected = r#"{"isoAddressClaim":
{"description":"ISO Address Claim"
,"51":{"prio":6,"src":51,"dst":255,"pgn":60928,"description":"ISO Address Claim","fields":{"uniqueNumber":1605586}}
}
}
"#;
assert_eq!(s.snapshot(), expected);
}
#[test]
fn ready_and_lazy_producers_share_one_store() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let s = SnapshotStore::new();
let ready_line = r#"{"pgn":127251,"src":7,"fields":{"Rate":0}}"#;
s.store(SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: ready_line.to_string(),
});
let renders = Arc::new(AtomicUsize::new(0));
let lazy_line = r#"{"pgn":127245,"src":9,"fields":{"Position":1.5}}"#;
let r = Arc::clone(&renders);
s.store_lazy(
127245,
9,
None,
false,
"Rudder",
Box::new(move || {
r.fetch_add(1, Ordering::Relaxed);
lazy_line.to_string()
}),
Instant::now(),
);
assert_eq!(
renders.load(Ordering::Relaxed),
0,
"Lazy payload must not serialize before a reader connects"
);
let dump = s.snapshot();
assert!(
dump.contains(ready_line),
"Ready line must be served byte-for-byte:\n{dump}"
);
assert!(
dump.contains(lazy_line),
"Lazy record must render into the same dump:\n{dump}"
);
assert_eq!(
renders.load(Ordering::Relaxed),
1,
"Lazy payload renders exactly once, on read"
);
}
#[test]
fn snapshot_keys_src_with_secondary_when_present() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 127501,
src: 17,
secondary: Some("0".to_string()),
is_ais: false,
pgn_description: "Binary Switch Bank Status".to_string(),
line: r#"{"pgn":127501,"src":17}"#.to_string(),
});
let dump = s.snapshot();
assert!(
dump.contains("\"17_0\":"),
"expected src_secondary key, got:\n{dump}"
);
assert!(dump.contains("\"description\":\"Binary Switch Bank Status\""));
}
#[test]
fn snapshot_filters_ais_described_pgns() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 129039,
src: 23,
secondary: Some("244180106".to_string()),
is_ais: true,
pgn_description: "AIS Class B Position Report".to_string(),
line: r#"{"pgn":129039}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 129026,
src: 52,
secondary: None,
is_ais: false,
pgn_description: "COG & SOG, Rapid Update".to_string(),
line: r#"{"pgn":129026}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: r#"{"pgn":127251}"#.to_string(),
});
let dump = s.snapshot();
assert!(
!dump.contains("\"129039\""),
"AIS-described PGN leaked into snapshot:\n{dump}"
);
assert!(
dump.contains("\"129026\""),
"special-cased PGN 129026 missing from snapshot:\n{dump}"
);
assert!(
dump.contains("\"127251\""),
"non-AIS sensor PGN dropped from snapshot:\n{dump}"
);
}
#[test]
fn snapshot_walks_pgns_in_insertion_order() {
let s = SnapshotStore::new();
let inputs = [
(65305, 21, "Simnet"),
(60928, 7, "ISO Address Claim"),
(127251, 27, "Rate of Turn"),
];
for (pgn, src, desc) in inputs {
s.store(SnapshotInput {
pgn,
src,
secondary: None,
is_ais: false,
pgn_description: desc.to_string(),
line: format!(r#"{{"pgn":{pgn},"src":{src}}}"#),
});
}
let dump = s.snapshot();
let pos_first = dump.find("65305").expect("first pgn present");
let pos_second = dump.find("60928").expect("second pgn present");
let pos_third = dump.find("127251").expect("third pgn present");
assert!(
pos_first < pos_second && pos_second < pos_third,
"expected insertion order 65305 < 60928 < 127251, got dump:\n{dump}"
);
}
#[test]
fn snapshot_preserves_position_when_existing_key_updated() {
let s = SnapshotStore::new();
for (pgn, line) in [(65305u32, "first-line"), (127251, "second-line")] {
s.store(SnapshotInput {
pgn,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "x".to_string(),
line: line.to_string(),
});
}
s.store(SnapshotInput {
pgn: 65305,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "x".to_string(),
line: "first-line-updated".to_string(),
});
let dump = s.snapshot();
let pos_first = dump.find("65305").expect("first pgn present");
let pos_second = dump.find("127251").expect("second pgn present");
assert!(
pos_first < pos_second,
"65305 must still precede 127251 after update; dump:\n{dump}"
);
assert!(dump.contains("first-line-updated"));
assert!(!dump.contains("\"first-line\""));
}
#[test]
fn store_overwrites_same_key() {
let s = SnapshotStore::new();
let mut input = SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: "old".to_string(),
};
s.store(input.clone());
input.line = "new".to_string();
s.store(input);
assert_eq!(s.len(), 1);
assert!(s.snapshot().contains("new"));
assert!(!s.snapshot().contains("old"));
}
#[test]
fn status_snapshot_emits_canboat_c_shape() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 65305,
src: 21,
secondary: None,
is_ais: false,
pgn_description: "Simnet: Device Mode Request".to_string(),
line: r#"{"pgn":65305}"#.to_string(),
});
let status = s.status_snapshot();
assert!(status.starts_with("{\"65305\":\n {\"description\":\"Simnet\""));
assert!(
status.contains("\"21\":{\"last\":\""),
"missing src key with last field: {status}"
);
assert!(
status.contains(",\"interval\":0,\"count\":1}"),
"missing interval/count tail: {status}"
);
assert!(status.ends_with("}\n"));
}
#[test]
fn status_snapshot_count_increments_across_stores() {
let s = SnapshotStore::new();
let template = SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: "ignored".to_string(),
};
for _ in 0..5 {
s.store(template.clone());
}
assert!(s.status_snapshot().contains("\"count\":5"));
}
#[test]
fn status_snapshot_uses_secondary_in_key() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 129039,
src: 23,
secondary: Some("244180106".to_string()),
is_ais: true,
pgn_description: "AIS Class B Position Report".to_string(),
line: "{}".to_string(),
});
let status = s.status_snapshot();
assert!(status.contains("\"23_244180106\":{\"last\":"));
}
#[test]
fn ais_snapshot_includes_ais_described_pgns_and_special_pgns() {
let s = SnapshotStore::new();
s.store(SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: r#"{"pgn":127251}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 129039,
src: 23,
secondary: Some("244180106".to_string()),
is_ais: true,
pgn_description: "AIS Class B Position Report".to_string(),
line: r#"{"pgn":129039}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 129026,
src: 52,
secondary: None,
is_ais: false,
pgn_description: "COG & SOG, Rapid Update".to_string(),
line: r#"{"pgn":129026}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 129029,
src: 52,
secondary: None,
is_ais: false,
pgn_description: "Position, Rapid Update".to_string(),
line: r#"{"pgn":129029}"#.to_string(),
});
let dump = s.ais_snapshot();
assert!(
dump.contains("\"129039\""),
"AIS-described PGN missing:\n{dump}"
);
assert!(
dump.contains("\"129026\""),
"special-cased PGN 129026 missing:\n{dump}"
);
assert!(
dump.contains("\"129029\""),
"special-cased PGN 129029 missing:\n{dump}"
);
assert!(
!dump.contains("\"127251\""),
"non-AIS sensor PGN leaked into ais dump:\n{dump}"
);
}
#[test]
fn ais_snapshot_empty_returns_blank_line() {
let s = SnapshotStore::new();
assert_eq!(s.ais_snapshot(), "\n");
s.store(SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: "{}".to_string(),
});
assert_eq!(s.ais_snapshot(), "\n");
}
#[test]
fn status_snapshot_empty_is_blank_line() {
assert_eq!(SnapshotStore::new().status_snapshot(), "\n");
}
fn fill_canon_corpus(s: &SnapshotStore) {
s.store(SnapshotInput {
pgn: 127251,
src: 7,
secondary: None,
is_ais: false,
pgn_description: "Rate of Turn".to_string(),
line: r#"{"pgn":127251,"src":7,"fields":{"Rate":0}}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 65305,
src: 17,
secondary: None,
is_ais: false,
pgn_description: "Simnet: Device Status".to_string(),
line: r#"{"pgn":65305,"src":17,"fields":{"x":1}}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 129039,
src: 23,
secondary: Some("244180106".to_string()),
is_ais: true,
pgn_description: "AIS Class B Position Report".to_string(),
line: r#"{"pgn":129039,"src":23,"fields":{"User ID":"244180106"}}"#.to_string(),
});
s.store(SnapshotInput {
pgn: 129026,
src: 52,
secondary: None,
is_ais: false,
pgn_description: "COG & SOG, Rapid Update".to_string(),
line: r#"{"pgn":129026,"src":52,"fields":{"COG":239.6}}"#.to_string(),
});
}
#[test]
fn snapshot_byte_exact_canboat_c_layout() {
let s = SnapshotStore::new();
fill_canon_corpus(&s);
let expected = r#"{"127251":
{"description":"Rate of Turn"
,"7":{"pgn":127251,"src":7,"fields":{"Rate":0}}
}
,"65305":
{"description":"Simnet"
,"17":{"pgn":65305,"src":17,"fields":{"x":1}}
}
,"129026":
{"description":"COG & SOG, Rapid Update"
,"52":{"pgn":129026,"src":52,"fields":{"COG":239.6}}
}
}
"#;
assert_eq!(s.snapshot(), expected);
}
#[test]
fn ais_snapshot_byte_exact_canboat_c_layout() {
let s = SnapshotStore::new();
fill_canon_corpus(&s);
let expected = r#"{"129039":
{"description":"AIS Class B Position Report"
,"23_244180106":{"pgn":129039,"src":23,"fields":{"User ID":"244180106"}}
}
,"129026":
{"description":"COG & SOG, Rapid Update"
,"52":{"pgn":129026,"src":52,"fields":{"COG":239.6}}
}
}
"#;
assert_eq!(s.ais_snapshot(), expected);
}
#[test]
fn status_snapshot_byte_exact_canboat_c_layout() {
let s = SnapshotStore::new();
fill_canon_corpus(&s);
let actual = redact_iso_last(&s.status_snapshot());
let expected = r#"{"127251":
{"description":"Rate of Turn"
,"7":{"last":"<ts>","interval":0,"count":1}
}
,"65305":
{"description":"Simnet"
,"17":{"last":"<ts>","interval":0,"count":1}
}
,"129039":
{"description":"AIS Class B Position Report"
,"23_244180106":{"last":"<ts>","interval":0,"count":1}
}
,"129026":
{"description":"COG & SOG, Rapid Update"
,"52":{"last":"<ts>","interval":0,"count":1}
}
}
"#;
assert_eq!(actual, expected);
}
fn redact_iso_last(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let needle = "\"last\":\"";
let mut rest = s;
while let Some(idx) = rest.find(needle) {
out.push_str(&rest[..idx + needle.len()]);
let after = &rest[idx + needle.len()..];
if let Some(end) = after.find('"') {
out.push_str("<ts>");
rest = &after[end..];
} else {
out.push_str(after);
return out;
}
}
out.push_str(rest);
out
}
}