use std::collections::HashMap;
use anyhow::Context;
use async_trait::async_trait;
use peat_mesh::sync::Document as MeshDocument;
use peat_mesh::transport::{TranslationContext, Translator};
use peat_protocol::cot::{CotEvent, CotPoint, CotType};
use serde_json::{json, Value};
const TAK_TRANSPORT_ID: &str = "tak";
#[derive(Debug, Clone)]
pub struct CotTranslatorConfig {
pub tracks_collection: String,
pub default_cot_type: String,
pub stale_secs: i64,
}
impl Default for CotTranslatorConfig {
fn default() -> Self {
Self {
tracks_collection: "tracks".to_string(),
default_cot_type: "a-f-G-U-C".to_string(),
stale_secs: 300,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct CotTranslator {
config: CotTranslatorConfig,
}
impl CotTranslator {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(config: CotTranslatorConfig) -> Self {
Self { config }
}
pub fn tracks_collection(&self) -> &str {
&self.config.tracks_collection
}
}
#[async_trait]
impl Translator for CotTranslator {
fn transport_id(&self) -> &'static str {
TAK_TRANSPORT_ID
}
async fn encode_outbound(
&self,
doc: &MeshDocument,
ctx: &TranslationContext,
) -> Option<Vec<u8>> {
let collection = ctx.collection.as_deref()?;
if collection != self.tracks_collection() {
return None;
}
let uid = doc.id.as_deref()?;
let lat = doc.fields.get("lat").and_then(Value::as_f64)?;
let lon = doc.fields.get("lon").and_then(Value::as_f64)?;
let callsign = doc
.fields
.get("callsign")
.and_then(Value::as_str)
.or(ctx.local_callsign.as_deref());
let cot_type_str = doc
.fields
.get("cot_type")
.and_then(Value::as_str)
.unwrap_or(&self.config.default_cot_type);
let hae = doc.fields.get("hae").and_then(Value::as_f64).unwrap_or(0.0);
let point = if hae != 0.0 {
CotPoint {
lat,
lon,
hae,
ce: 9_999_999.0,
le: 9_999_999.0,
}
} else {
CotPoint::new(lat, lon)
};
let mut builder = CotEvent::builder()
.uid(uid)
.cot_type(CotType::new(cot_type_str))
.stale_duration(chrono::Duration::seconds(self.config.stale_secs))
.point(point);
if let Some(cs) = callsign {
builder = builder.callsign(cs);
}
let event = match builder.build() {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, uid = %uid, "tak: CoT builder failed");
return None;
}
};
match event.to_xml() {
Ok(xml) => Some(xml.into_bytes()),
Err(e) => {
tracing::warn!(error = %e, uid = %uid, "tak: CoT XML serialization failed");
None
}
}
}
async fn decode_inbound(
&self,
bytes: &[u8],
_ctx: &TranslationContext,
) -> anyhow::Result<Option<MeshDocument>> {
let xml = std::str::from_utf8(bytes).context("tak: bytes not valid UTF-8")?;
let event = CotEvent::from_xml(xml).context("tak: parse CoT XML")?;
if !event.cot_type.is_atom() {
return Ok(None);
}
let mut fields = HashMap::new();
fields.insert("lat".into(), json!(event.point.lat));
fields.insert("lon".into(), json!(event.point.lon));
if event.point.hae != 0.0 {
fields.insert("hae".into(), json!(event.point.hae));
}
fields.insert(
"cot_type".into(),
Value::String(event.cot_type.as_str().to_string()),
);
if let Some(cs) = &event.detail.contact_callsign {
fields.insert("callsign".into(), Value::String(cs.clone()));
}
fields.insert("timestamp_ms".into(), json!(event.time.timestamp_millis()));
fields.insert("tak_origin".into(), Value::Bool(true));
Ok(Some(MeshDocument::with_id(event.uid, fields)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track_doc() -> MeshDocument {
let mut fields = HashMap::new();
fields.insert("lat".into(), json!(34.123));
fields.insert("lon".into(), json!(-118.456));
fields.insert("callsign".into(), json!("ALPHA-1"));
MeshDocument::with_id("track-uid-1".to_string(), fields)
}
#[test]
fn transport_id_is_tak_static() {
let t = CotTranslator::new();
let id: &'static str = t.transport_id();
assert_eq!(id, "tak");
}
#[tokio::test]
async fn encode_declines_when_no_collection() {
let t = CotTranslator::new();
let doc = track_doc();
let ctx = TranslationContext::outbound();
assert_eq!(t.encode_outbound(&doc, &ctx).await, None);
}
#[tokio::test]
async fn encode_declines_unknown_collection() {
let t = CotTranslator::new();
let doc = track_doc();
let ctx = TranslationContext::outbound().with_collection("alerts");
assert_eq!(t.encode_outbound(&doc, &ctx).await, None);
}
#[tokio::test]
async fn encode_declines_when_required_fields_missing() {
let t = CotTranslator::new();
let mut fields = HashMap::new();
fields.insert("callsign".into(), json!("ALPHA-1"));
let doc = MeshDocument::with_id("track-uid-2".to_string(), fields);
let ctx = TranslationContext::outbound().with_collection("tracks");
assert_eq!(
t.encode_outbound(&doc, &ctx).await,
None,
"missing lat/lon must decline, not panic"
);
}
#[tokio::test]
async fn encode_emits_cot_xml_with_uid_and_callsign() {
let t = CotTranslator::new();
let doc = track_doc();
let ctx = TranslationContext::outbound().with_collection("tracks");
let bytes = t
.encode_outbound(&doc, &ctx)
.await
.expect("track must encode");
let xml = std::str::from_utf8(&bytes).expect("xml is utf-8");
assert!(
xml.contains("uid=\"track-uid-1\""),
"xml missing uid: {xml}"
);
assert!(
xml.contains("type=\"a-f-G-U-C\""),
"xml missing default cot_type: {xml}"
);
assert!(xml.contains("ALPHA-1"), "xml missing callsign: {xml}");
}
#[tokio::test]
async fn encode_uses_ctx_local_callsign_when_doc_has_none() {
let t = CotTranslator::new();
let mut fields = HashMap::new();
fields.insert("lat".into(), json!(0.0));
fields.insert("lon".into(), json!(0.0));
let doc = MeshDocument::with_id("no-callsign-doc".to_string(), fields);
let ctx = TranslationContext::outbound()
.with_collection("tracks")
.with_callsign("CTX-FALLBACK");
let bytes = t
.encode_outbound(&doc, &ctx)
.await
.expect("doc without callsign + ctx.local_callsign must encode");
let xml = std::str::from_utf8(&bytes).unwrap();
assert!(
xml.contains("CTX-FALLBACK"),
"ctx.local_callsign must be used when doc has no callsign field: {xml}"
);
}
#[tokio::test]
async fn encode_doc_callsign_wins_over_ctx_callsign() {
let t = CotTranslator::new();
let mut fields = HashMap::new();
fields.insert("lat".into(), json!(0.0));
fields.insert("lon".into(), json!(0.0));
fields.insert("callsign".into(), json!("DOC-WINS"));
let doc = MeshDocument::with_id("doc-callsign-1".to_string(), fields);
let ctx = TranslationContext::outbound()
.with_collection("tracks")
.with_callsign("CTX-LOSES");
let bytes = t.encode_outbound(&doc, &ctx).await.unwrap();
let xml = std::str::from_utf8(&bytes).unwrap();
assert!(
xml.contains("DOC-WINS"),
"doc-supplied callsign missing: {xml}"
);
assert!(
!xml.contains("CTX-LOSES"),
"ctx fallback must not appear when doc supplies callsign: {xml}"
);
}
#[tokio::test]
async fn encode_honors_doc_supplied_cot_type() {
let t = CotTranslator::new();
let mut fields = HashMap::new();
fields.insert("lat".into(), json!(0.0));
fields.insert("lon".into(), json!(0.0));
fields.insert("cot_type".into(), json!("a-h-G")); let doc = MeshDocument::with_id("hostile-1".to_string(), fields);
let ctx = TranslationContext::outbound().with_collection("tracks");
let bytes = t
.encode_outbound(&doc, &ctx)
.await
.expect("hostile track must encode");
let xml = std::str::from_utf8(&bytes).unwrap();
assert!(
xml.contains("type=\"a-h-G\""),
"doc-supplied cot_type must override default: {xml}"
);
}
#[tokio::test]
async fn decode_produces_document_with_origin_marker() {
let t = CotTranslator::new();
let doc = track_doc();
let ctx_out = TranslationContext::outbound().with_collection("tracks");
let bytes = t.encode_outbound(&doc, &ctx_out).await.unwrap();
let ctx_in = TranslationContext::inbound("tak-server-1");
let decoded = t
.decode_inbound(&bytes, &ctx_in)
.await
.expect("decode must not error")
.expect("atom CoT must produce a doc");
assert_eq!(decoded.id.as_deref(), Some("track-uid-1"));
assert_eq!(
decoded.fields.get("tak_origin").and_then(Value::as_bool),
Some(true),
"tak_origin marker required so listeners can attribute"
);
}
#[tokio::test]
async fn roundtrip_preserves_uid_lat_lon_callsign_cot_type() {
let t = CotTranslator::new();
let doc = track_doc();
let ctx_out = TranslationContext::outbound().with_collection("tracks");
let bytes = t.encode_outbound(&doc, &ctx_out).await.unwrap();
let ctx_in = TranslationContext::inbound("tak-server-1");
let decoded = t.decode_inbound(&bytes, &ctx_in).await.unwrap().unwrap();
assert_eq!(decoded.id.as_deref(), Some("track-uid-1"));
assert_eq!(
decoded.fields.get("lat").and_then(Value::as_f64),
Some(34.123)
);
assert_eq!(
decoded.fields.get("lon").and_then(Value::as_f64),
Some(-118.456)
);
assert_eq!(
decoded.fields.get("callsign").and_then(Value::as_str),
Some("ALPHA-1")
);
assert_eq!(
decoded.fields.get("cot_type").and_then(Value::as_str),
Some("a-f-G-U-C")
);
}
#[tokio::test]
async fn decode_returns_ok_none_for_non_atom_cot_type() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="drawing-1" type="u-d-r" time="2026-01-01T00:00:00Z" start="2026-01-01T00:00:00Z" stale="2026-01-01T00:05:00Z" how="m-g">
<point lat="0.0" lon="0.0" hae="0.0" ce="9999999.0" le="9999999.0"/>
<detail/>
</event>"#;
let t = CotTranslator::new();
let ctx = TranslationContext::inbound("peer");
let result = t.decode_inbound(xml.as_bytes(), &ctx).await.unwrap();
assert!(
result.is_none(),
"drawing CoT must Ok(None), not Err — non-atom is well-formed but not a track"
);
}
#[tokio::test]
async fn decode_errors_on_malformed_xml() {
let t = CotTranslator::new();
let ctx = TranslationContext::inbound("peer");
let result = t.decode_inbound(b"<not-cot/>garbage", &ctx).await;
assert!(
result.is_err(),
"malformed CoT must Err — wire-format drift diagnostic per trait docs"
);
}
#[tokio::test]
async fn decode_errors_on_non_utf8_bytes() {
let t = CotTranslator::new();
let ctx = TranslationContext::inbound("peer");
let result = t.decode_inbound(&[0xff, 0xfe, 0xfd], &ctx).await;
assert!(result.is_err(), "non-UTF-8 bytes must Err");
}
}