use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::net::tcp::OwnedWriteHalf;
use tokio::sync::{Mutex, mpsc};
use tokio::task::JoinHandle;
use tokio::time::timeout;
use crate::cli::tui::state::{AppState, Progress};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const SNAPSHOT_READ_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Clone)]
pub struct Writer {
input_tx: mpsc::UnboundedSender<String>,
filter_tx: mpsc::UnboundedSender<String>,
overrides_tx: mpsc::UnboundedSender<String>,
}
impl Writer {
pub fn send(&self, line: String) -> bool {
if line_is_filter_control(&line) {
self.filter_tx.send(line).is_ok()
} else if line_is_overrides_control(&line) {
self.overrides_tx.send(line).is_ok()
} else {
self.input_tx.send(line).is_ok()
}
}
}
pub struct WriterRx {
pub input: mpsc::UnboundedReceiver<String>,
pub filter: mpsc::UnboundedReceiver<String>,
pub overrides: mpsc::UnboundedReceiver<String>,
}
fn line_pgn(line: &str) -> Option<u32> {
line.split(',')
.nth(2)
.and_then(|s| s.trim().parse::<u32>().ok())
}
fn line_is_filter_control(line: &str) -> bool {
line_pgn(line) == Some(crate::n2kd::nmea_filter::PGN_NMEA0183_FILTER)
}
fn line_is_overrides_control(line: &str) -> bool {
line_pgn(line) == Some(crate::n2kd::overrides::PGN_PGN_OVERRIDE)
}
pub fn make_writer() -> (Writer, WriterRx) {
let (input_tx, input) = mpsc::unbounded_channel();
let (filter_tx, filter) = mpsc::unbounded_channel();
let (overrides_tx, overrides) = mpsc::unbounded_channel();
(
Writer {
input_tx,
filter_tx,
overrides_tx,
},
WriterRx {
input,
filter,
overrides,
},
)
}
enum LoadItem {
Frame(String),
Record(crate::engine::snapshot::SnapshotInput),
}
fn looks_like_json_capture(path: &Path) -> bool {
use std::io::BufRead;
let Ok(file) = std::fs::File::open(path) else {
return false;
};
for line in std::io::BufReader::new(file).lines().map_while(Result::ok) {
let t = line.trim_start();
if t.is_empty() || t.starts_with('#') {
continue;
}
return t.starts_with('{');
}
false
}
fn ingest_raw_capture(path: &Path, tx: &mpsc::UnboundedSender<LoadItem>) -> anyhow::Result<()> {
use crate::engine::format::write_plain;
use crate::engine::frame::RawFrame;
use crate::engine::output::{JsonOptions, write_json};
use crate::engine::snapshot::classify_json_line;
let cfg = crate::io::analyze::Config {
units: crate::engine::Units::Metric,
..Default::default()
};
let json_opts = JsonOptions {
camel_case: crate::engine::output::CamelCase::Off,
..Default::default()
};
let mut buf = String::with_capacity(512);
let mut raw = String::with_capacity(128);
crate::io::analyze::decode_file(path, &cfg, |decoded| {
raw.clear();
let frame = RawFrame::new(
decoded.timestamp.clone(),
decoded.prio,
decoded.pgn,
decoded.src,
decoded.dst,
decoded.data.iter().copied(),
);
if write_plain(&mut raw, &frame).is_ok() {
let _ = tx.send(LoadItem::Frame(raw.clone()));
}
buf.clear();
if write_json(&mut buf, decoded, &json_opts).is_err() {
return;
}
classify_json_line(&buf, |input| {
let _ = tx.send(LoadItem::Record(input));
});
})?;
Ok(())
}
fn ingest_json_capture(path: &Path, tx: &mpsc::UnboundedSender<LoadItem>) -> anyhow::Result<()> {
use crate::engine::snapshot::classify_json_line;
use std::io::BufRead;
let file = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
for line in std::io::BufReader::new(file).lines() {
let line = line.context("reading JSON capture")?;
let t = line.trim();
if t.is_empty() || t.starts_with('#') {
continue;
}
classify_json_line(t, |input| {
let _ = tx.send(LoadItem::Record(input));
});
}
Ok(())
}
pub fn spawn_log_load(path: PathBuf, state: Arc<Mutex<AppState>>) -> Vec<JoinHandle<()>> {
let (tx, mut rx) = mpsc::unbounded_channel::<LoadItem>();
let path_for_blocker = path.clone();
let state_for_err = state.clone();
let decode = tokio::task::spawn_blocking(move || {
let result = if crate::io::container::is_container(&path_for_blocker) {
ingest_raw_capture(&path_for_blocker, &tx)
} else if looks_like_json_capture(&path_for_blocker) {
ingest_json_capture(&path_for_blocker, &tx)
} else {
ingest_raw_capture(&path_for_blocker, &tx)
};
if let Err(e) = result {
tokio::runtime::Handle::current().block_on(async {
let mut s = state_for_err.lock().await;
s.status.last_error = Some(format!("log: {e:#}"));
});
}
});
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let apply = tokio::spawn(async move {
let mut count = 0usize;
while let Some(item) = rx.recv().await {
match item {
LoadItem::Frame(line) => {
let mut s = state.lock().await;
s.raw_lines.push(line);
}
LoadItem::Record(input) => {
let value: Value = match serde_json::from_str(&input.line) {
Ok(v) => v,
Err(_) => continue,
};
let mut s = state.lock().await;
s.upsert(
input.pgn,
input.src,
input.secondary,
input.pgn_description,
value,
);
count += 1;
}
}
}
let mut s = state.lock().await;
s.status.snapshot_loaded = true;
s.notice = Some(format!("✓ Loaded {count} records — {name}"));
});
vec![decode, apply]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveFormat {
Analysed,
Raw,
}
impl SaveFormat {
pub fn extension(self) -> &'static str {
match self {
SaveFormat::Analysed => "json",
SaveFormat::Raw => "raw",
}
}
}
#[cfg(test)]
pub fn save_capture(path: &Path, state: &AppState, format: SaveFormat) -> Result<usize> {
use std::fs::File;
use std::io::{BufWriter, Write};
let file = File::create(path).with_context(|| format!("creating {}", path.display()))?;
let mut w = BufWriter::new(file);
let mut n = 0usize;
match format {
SaveFormat::Analysed => {
for rec in &state.history {
serde_json::to_writer(&mut w, &rec.line).context("serialising record")?;
w.write_all(b"\n").context("writing capture")?;
n += 1;
}
}
SaveFormat::Raw => {
w.write_all(b"# format=FAST\n").context("writing header")?;
for line in &state.raw_lines {
w.write_all(line.as_bytes()).context("writing frame")?;
w.write_all(b"\n").context("writing frame")?;
n += 1;
}
}
}
w.flush().context("flushing capture")?;
Ok(n)
}
pub fn spawn_save(
path: PathBuf,
format: SaveFormat,
state: Arc<Mutex<AppState>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = run_save(&path, format, &state).await {
let mut s = state.lock().await;
s.save_progress = None;
s.status.last_error = Some(format!("save: {e:#}"));
}
})
}
async fn run_save(path: &Path, format: SaveFormat, state: &Arc<Mutex<AppState>>) -> Result<()> {
use tokio::io::BufWriter;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("capture")
.to_string();
let total = {
let mut s = state.lock().await;
let total = match format {
SaveFormat::Analysed => s.history.len(),
SaveFormat::Raw => s.raw_lines.len(),
};
s.save_progress = Some(Progress {
label: format!("Saving {name}"),
done: 0,
total,
});
total
};
let file = tokio::fs::File::create(path)
.await
.with_context(|| format!("creating {}", path.display()))?;
let mut w = BufWriter::new(file);
if matches!(format, SaveFormat::Raw) {
w.write_all(b"# format=FAST\n")
.await
.context("writing header")?;
}
const CHUNK: usize = 2000;
let mut done = 0usize;
let mut buf = String::with_capacity(64 * 1024);
while done < total {
let end = (done + CHUNK).min(total);
buf.clear();
{
let s = state.lock().await;
match format {
SaveFormat::Analysed => {
for i in done..end {
let Some(rec) = s.history.get(i) else { break };
buf.push_str(&serde_json::to_string(&rec.line).unwrap_or_default());
buf.push('\n');
}
}
SaveFormat::Raw => {
for i in done..end {
let Some(line) = s.raw_lines.get(i) else {
break;
};
buf.push_str(line);
buf.push('\n');
}
}
}
}
w.write_all(buf.as_bytes())
.await
.context("writing capture")?;
done = end;
{
let mut s = state.lock().await;
if let Some(p) = &mut s.save_progress {
p.done = done;
}
}
tokio::task::yield_now().await;
}
w.flush().await.context("flushing capture")?;
let mut s = state.lock().await;
s.save_progress = None;
s.notice = Some(if done == 0 && format == SaveFormat::Raw {
"Saved 0 raw frames — live JSON has no wire bytes; load a capture to export raw".to_string()
} else {
format!("✓ Saved {done} records → {}", path.display())
});
Ok(())
}
pub fn spawn_snapshot_load(host: String, port: u16, state: Arc<Mutex<AppState>>) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = load_snapshot(&host, port, state.clone()).await {
let mut s = state.lock().await;
s.status.last_error = Some(format!("snapshot: {e:#}"));
}
})
}
pub fn spawn_stream_connection(
host: String,
port: u16,
state: Arc<Mutex<AppState>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = connect_stream(&host, port, state.clone()).await {
let mut s = state.lock().await;
s.status.last_error = Some(format!("stream: {e:#}"));
}
})
}
pub fn spawn_input_connection(
host: String,
port: u16,
state: Arc<Mutex<AppState>>,
rx: mpsc::UnboundedReceiver<String>,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = connect_input(&host, port, rx, state.clone()).await {
let mut s = state.lock().await;
s.status.last_error = Some(format!("input: {e:#}"));
}
})
}
pub fn spawn_filter_connection(
host: String,
port: u16,
state: Arc<Mutex<AppState>>,
rx: mpsc::UnboundedReceiver<String>,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = connect_filter(&host, port, state.clone(), rx).await {
log::debug!("filter control connection to {host}:{port} ended: {e:#}");
}
})
}
pub fn spawn_overrides_connection(
host: String,
port: u16,
state: Arc<Mutex<AppState>>,
rx: mpsc::UnboundedReceiver<String>,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = connect_overrides(&host, port, state.clone(), rx).await {
log::debug!("override control connection to {host}:{port} ended: {e:#}");
}
})
}
async fn load_snapshot(host: &str, port: u16, state: Arc<Mutex<AppState>>) -> Result<()> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
.await
.with_context(|| format!("connect snapshot port {host}:{port} timed out"))?
.with_context(|| format!("connecting snapshot port {host}:{port}"))?;
let mut reader = BufReader::new(stream);
let mut buf = String::with_capacity(64 * 1024);
timeout(SNAPSHOT_READ_TIMEOUT, reader.read_to_string(&mut buf))
.await
.context("reading snapshot blob timed out (peer did not close)")?
.context("reading snapshot blob")?;
let doc = strip_snapshot_banner(&buf);
if doc.is_empty() {
let mut s = state.lock().await;
s.status.snapshot_loaded = true;
return Ok(());
}
let mut guard = state.lock().await;
seed_state_from_snapshot(&mut guard, doc)?;
guard.status.snapshot_loaded = true;
Ok(())
}
fn seed_state_from_snapshot(state: &mut AppState, doc: &str) -> Result<()> {
let root: Value = serde_json::from_str(doc).context("parsing snapshot JSON")?;
let Some(by_pgn) = root.as_object() else {
anyhow::bail!("snapshot top-level is not a JSON object");
};
for (group_key, group) in by_pgn {
let Some(group_obj) = group.as_object() else {
continue;
};
let camel_id: Option<&str> = match group_key.parse::<u32>() {
Ok(_) => None,
Err(_) => Some(group_key.as_str()),
};
for (key, line_val) in group_obj {
if key == "description" {
continue;
}
let (src_str, secondary) = match key.split_once('_') {
Some((s, sec)) => (s, Some(sec.to_string())),
None => (key.as_str(), None),
};
let Ok(src) = src_str.parse::<u8>() else {
continue;
};
let pgn = line_val
.pointer("/pgn")
.and_then(Value::as_u64)
.map(|n| n as u32)
.or_else(|| group_key.parse::<u32>().ok())
.unwrap_or(0);
let description = line_val
.pointer("/description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let record = match camel_id {
Some(id) => serde_json::json!({ id: line_val.clone() }),
None => line_val.clone(),
};
state.upsert(pgn, src, secondary, description, record);
}
}
Ok(())
}
async fn connect_stream(host: &str, port: u16, state: Arc<Mutex<AppState>>) -> Result<()> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
.await
.with_context(|| format!("connect stream port {host}:{port} timed out"))?
.with_context(|| format!("connecting stream port {host}:{port}"))?;
stream.set_nodelay(true).ok();
let (read_half, _write_half) = stream.into_split();
{
let mut s = state.lock().await;
s.status.stream_connected = true;
}
reader_task(read_half, state.clone()).await;
let mut s = state.lock().await;
s.status.stream_connected = false;
Ok(())
}
async fn connect_input(
host: &str,
port: u16,
rx: mpsc::UnboundedReceiver<String>,
state: Arc<Mutex<AppState>>,
) -> Result<()> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
.await
.with_context(|| format!("connect input port {host}:{port} timed out"))?
.with_context(|| format!("connecting input port {host}:{port}"))?;
stream.set_nodelay(true).ok();
let (_read_half, write_half) = stream.into_split();
writer_task(write_half, rx, state).await;
Ok(())
}
async fn connect_filter(
host: &str,
port: u16,
state: Arc<Mutex<AppState>>,
rx: mpsc::UnboundedReceiver<String>,
) -> Result<()> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
.await
.with_context(|| format!("connect filter port {host}:{port} timed out"))?
.with_context(|| format!("connecting filter port {host}:{port}"))?;
stream.set_nodelay(true).ok();
let (read_half, write_half) = stream.into_split();
tokio::select! {
_ = reader_task(read_half, state.clone()) => {}
_ = filter_writer_task(write_half, rx, state.clone()) => {}
}
Ok(())
}
async fn connect_overrides(
host: &str,
port: u16,
state: Arc<Mutex<AppState>>,
rx: mpsc::UnboundedReceiver<String>,
) -> Result<()> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port)))
.await
.with_context(|| format!("connect overrides port {host}:{port} timed out"))?
.with_context(|| format!("connecting overrides port {host}:{port}"))?;
stream.set_nodelay(true).ok();
let (read_half, write_half) = stream.into_split();
tokio::select! {
_ = reader_task(read_half, state.clone()) => {}
_ = overrides_writer_task(write_half, rx, state.clone()) => {}
}
Ok(())
}
async fn overrides_writer_task(
mut writer: OwnedWriteHalf,
mut rx: mpsc::UnboundedReceiver<String>,
state: Arc<Mutex<AppState>>,
) {
let mut poll = tokio::time::interval(crate::n2kd::nmea_filter::FILTER_REPORT_INTERVAL);
poll.tick().await;
loop {
let mut line = tokio::select! {
maybe = rx.recv() => match maybe {
Some(l) => l,
None => break,
},
_ = poll.tick() => crate::cli::tui::iso::override_request(),
};
if !line.ends_with('\n') {
line.push('\n');
}
if let Err(e) = writer.write_all(line.as_bytes()).await {
let mut s = state.lock().await;
s.status.last_error = Some(format!("overrides write: {e}"));
break;
}
}
}
async fn filter_writer_task(
mut writer: OwnedWriteHalf,
mut rx: mpsc::UnboundedReceiver<String>,
state: Arc<Mutex<AppState>>,
) {
let mut poll = tokio::time::interval(crate::n2kd::nmea_filter::FILTER_REPORT_INTERVAL);
poll.tick().await;
loop {
let mut line = tokio::select! {
maybe = rx.recv() => match maybe {
Some(l) => l,
None => break,
},
_ = poll.tick() => crate::cli::tui::iso::nmea0183_filter_request(),
};
if !line.ends_with('\n') {
line.push('\n');
}
if let Err(e) = writer.write_all(line.as_bytes()).await {
let mut s = state.lock().await;
s.status.last_error = Some(format!("filter write: {e}"));
break;
}
}
}
async fn reader_task(reader: tokio::net::tcp::OwnedReadHalf, state: Arc<Mutex<AppState>>) {
let mut br = BufReader::new(reader);
let mut line = String::with_capacity(4096);
loop {
line.clear();
match br.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
let mut s = state.lock().await;
s.status.last_error = Some(format!("stream read: {e}"));
break;
}
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("{\"version\"") {
continue;
}
if !trimmed.ends_with("}}") {
continue;
}
let mut inputs = Vec::new();
crate::engine::snapshot::classify_json_line(trimmed, |i| inputs.push(i));
if inputs.is_empty() {
continue;
}
let mut s = state.lock().await;
for input in inputs {
let value = match serde_json::from_str::<Value>(&input.line) {
Ok(v) => v,
Err(_) => continue,
};
s.upsert(
input.pgn,
input.src,
input.secondary,
input.pgn_description,
value,
);
}
}
let mut s = state.lock().await;
s.status.stream_connected = false;
}
fn strip_snapshot_banner(payload: &str) -> &str {
match payload.split_once('\n') {
Some((first, rest)) if first.trim_start().starts_with("{\"version\"") => rest.trim(),
_ => payload.trim(),
}
}
async fn writer_task(
mut writer: OwnedWriteHalf,
mut rx: mpsc::UnboundedReceiver<String>,
state: Arc<Mutex<AppState>>,
) {
while let Some(mut line) = rx.recv().await {
if !line.ends_with('\n') {
line.push('\n');
}
if let Err(e) = writer.write_all(line.as_bytes()).await {
let mut s = state.lock().await;
s.status.last_error = Some(format!("stream write: {e}"));
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tui::state::Status;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn snapshot_banner_is_stripped() {
let with = "{\"version\":\"0.5.0\",\"units\":\"si\"}\n{\"130306\":{\"7\":{}}}";
assert_eq!(strip_snapshot_banner(with), "{\"130306\":{\"7\":{}}}");
let without = "{\"130306\":{\"7\":{}}}";
assert_eq!(strip_snapshot_banner(without), without);
let pretty = "{\n \"130306\": {}\n}";
assert_eq!(strip_snapshot_banner(pretty), pretty);
assert_eq!(strip_snapshot_banner("{\"version\":\"x\"}\n\n"), "");
}
#[test]
fn camel_snapshot_seeds_device_cache() {
use crate::cli::tui::device_cache::NameKey;
use crate::engine::snapshot::{SnapshotInput, SnapshotStore};
let store = SnapshotStore::new();
store.store(SnapshotInput {
pgn: 60928,
src: 51,
secondary: None,
is_ais: false,
pgn_description: "ISO Address Claim".into(),
line: r#"{"isoAddressClaim":{"prio":6,"src":51,"dst":255,"pgn":60928,"description":"isoAddressClaim","fields":{"uniqueNumber":1605586,"manufacturerCode":{"value":419,"name":"Fusion Electronics"},"deviceFunction":{"value":130,"name":"Multimedia Player"},"deviceClass":{"value":125,"name":"Entertainment"},"industryGroup":{"value":4,"name":"Marine Industry"}}}}"#.into(),
});
store.store(SnapshotInput {
pgn: 126996,
src: 51,
secondary: None,
is_ais: false,
pgn_description: "Product Information".into(),
line: r#"{"productInformation":{"prio":6,"src":51,"dst":255,"pgn":126996,"description":"productInformation","fields":{"nmea2000Version":2100,"productCode":419,"modelId":"FUSION-MS","certificationLevel":2,"loadEquivalency":1}}}"#.into(),
});
let doc = store.snapshot();
let mut s = AppState::new(Status::new_log("x".into()), HashMap::new());
seed_state_from_snapshot(&mut s, &doc).expect("seed camel snapshot");
let key = NameKey {
manufacturer_code: 419,
unique_number: 1605586,
};
let cached = s.device_cache.get(&key).expect("device cached under NAME");
assert_eq!(
cached.manufacturer_name.as_deref(),
Some("Fusion Electronics")
);
assert_eq!(cached.product_code, Some(419));
assert_eq!(cached.model_id.as_deref(), Some("FUSION-MS"));
}
#[test]
fn filter_control_lines_are_recognised_by_pgn() {
assert!(line_is_filter_control(
"2026-01-01T00:00:00.000Z,7,262657,0,255,8,01,21,41,4c,4c,01,ff,ff"
));
assert!(!line_is_filter_control(
"2026-01-01T00:00:00.000Z,6,59904,0,35,3,00,ee,01"
));
assert!(!line_is_filter_control("garbage"));
}
#[test]
fn overrides_control_lines_are_recognised_by_pgn() {
assert!(line_is_overrides_control(
"2026-01-01T00:00:00.000Z,7,262658,0,255,12,01,34,12,fe,01,e8,03,00,00,ff,ff,ff"
));
assert!(!line_is_overrides_control(
"2026-01-01T00:00:00.000Z,7,262657,0,255,8,01,21,41,4c,4c,01,ff,ff"
));
assert!(!line_is_overrides_control("garbage"));
}
#[test]
fn writer_routes_by_pgn() {
let (writer, mut rx) = make_writer();
assert!(writer.send(crate::cli::tui::iso::nmea0183_filter_request()));
assert!(writer.send(crate::cli::tui::iso::override_request()));
assert!(writer.send(crate::cli::tui::iso::iso_request(35, 126464)));
assert!(rx.filter.try_recv().is_ok());
assert!(rx.overrides.try_recv().is_ok());
assert!(rx.input.try_recv().is_ok());
assert!(rx.filter.try_recv().is_err());
assert!(rx.overrides.try_recv().is_err());
assert!(rx.input.try_recv().is_err());
}
fn seeded() -> AppState {
let mut s = AppState::new(Status::new_log("x".into()), HashMap::new());
s.upsert(
129025,
10,
None,
"Position".into(),
json!({"pgn": 129025, "fields": {"Latitude": 1.0}}),
);
s.upsert(127250, 10, None, "Heading".into(), json!({"pgn": 127250}));
s
}
#[test]
fn save_analysed_writes_one_json_line_per_record() {
let s = seeded();
let path =
std::env::temp_dir().join(format!("canboat-tui-json-{}.json", std::process::id()));
let n = save_capture(&path, &s, SaveFormat::Analysed).unwrap();
assert_eq!(n, 2);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
assert!(contents.contains("129025"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_raw_writes_header_and_captured_frames() {
let mut s = seeded();
s.raw_lines
.push("2024-01-01T00:00:00.000,2,129025,10,255,8,01,02,03,04,05,06,07,08".into());
let path = std::env::temp_dir().join(format!("canboat-tui-raw-{}.raw", std::process::id()));
let n = save_capture(&path, &s, SaveFormat::Raw).unwrap();
assert_eq!(n, 1);
let contents = std::fs::read_to_string(&path).unwrap();
let mut lines = contents.lines();
assert_eq!(lines.next(), Some("# format=FAST"));
assert!(lines.next().unwrap().contains(",129025,"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn analysed_export_is_detected_and_reingestible() {
let mut s = AppState::new(Status::new_log("x".into()), HashMap::new());
s.upsert(
129025,
10,
None,
"Position".into(),
json!({"pgn": 129025, "src": 10, "fields": {"Latitude": 1.0}}),
);
s.upsert(
127250,
10,
None,
"Heading".into(),
json!({"pgn": 127250, "src": 10, "fields": {"Heading": 2.0}}),
);
let path = std::env::temp_dir().join(format!("canboat-tui-rt-{}.json", std::process::id()));
save_capture(&path, &s, SaveFormat::Analysed).unwrap();
assert!(looks_like_json_capture(&path));
let (tx, mut rx) = mpsc::unbounded_channel::<LoadItem>();
ingest_json_capture(&path, &tx).unwrap();
drop(tx);
let mut records = 0;
while let Ok(item) = rx.try_recv() {
if matches!(item, LoadItem::Record(_)) {
records += 1;
}
}
assert_eq!(records, 2);
let _ = std::fs::remove_file(&path);
}
#[test]
fn raw_capture_is_not_mistaken_for_json() {
let path =
std::env::temp_dir().join(format!("canboat-tui-rawsniff-{}.raw", std::process::id()));
std::fs::write(
&path,
"# format=FAST\n2024-01-01T00:00:00.000,2,129025,10,255,8,01,02,03,04,05,06,07,08\n",
)
.unwrap();
assert!(!looks_like_json_capture(&path));
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn spawn_save_writes_records_and_clears_progress() {
let state = Arc::new(Mutex::new(seeded()));
let path =
std::env::temp_dir().join(format!("canboat-tui-async-{}.json", std::process::id()));
spawn_save(path.clone(), SaveFormat::Analysed, state.clone())
.await
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
assert!(contents.contains("129025"));
let s = state.lock().await;
assert!(s.save_progress.is_none(), "progress cleared when done");
assert!(
s.notice.as_deref().unwrap().contains("Saved 2 records"),
"completion notice raised"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn save_raw_empty_in_live_mode_still_writes_header() {
let s = AppState::new(Status::new_live("h".into(), 1, 2), HashMap::new());
let path =
std::env::temp_dir().join(format!("canboat-tui-raw-empty-{}.raw", std::process::id()));
let n = save_capture(&path, &s, SaveFormat::Raw).unwrap();
assert_eq!(n, 0);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "# format=FAST\n");
let _ = std::fs::remove_file(&path);
}
}