use chrono::{DateTime, Duration, Utc};
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Reader;
use quick_xml::Writer;
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use super::peat_extension::PeatExtension;
use super::type_mapper::{CotRelation, CotType};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CotEvent {
pub version: String,
pub uid: String,
pub cot_type: CotType,
pub time: DateTime<Utc>,
pub start: DateTime<Utc>,
pub stale: DateTime<Utc>,
pub how: String,
pub point: CotPoint,
pub detail: CotDetail,
}
impl CotEvent {
pub fn builder() -> CotEventBuilder {
CotEventBuilder::new()
}
pub fn to_xml(&self) -> Result<String, CotError> {
let mut writer = Writer::new(Cursor::new(Vec::new()));
writer
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
let time_str = self.format_time(&self.time);
let start_str = self.format_time(&self.start);
let stale_str = self.format_time(&self.stale);
let mut event_elem = BytesStart::new("event");
event_elem.push_attribute(("version", self.version.as_str()));
event_elem.push_attribute(("uid", self.uid.as_str()));
event_elem.push_attribute(("type", self.cot_type.as_str()));
event_elem.push_attribute(("time", time_str.as_str()));
event_elem.push_attribute(("start", start_str.as_str()));
event_elem.push_attribute(("stale", stale_str.as_str()));
event_elem.push_attribute(("how", self.how.as_str()));
writer
.write_event(Event::Start(event_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
self.write_point(&mut writer)?;
self.write_detail(&mut writer)?;
writer
.write_event(Event::End(BytesEnd::new("event")))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
let result = writer.into_inner().into_inner();
String::from_utf8(result).map_err(|e| CotError::Encoding(e.to_string()))
}
pub fn from_xml(xml: &str) -> Result<Self, CotError> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut uid = None;
let mut cot_type = None;
let mut time = None;
let mut start = None;
let mut stale = None;
let mut how = String::from("m-g");
let mut point = None;
let mut detail = CotDetail::default();
let mut buf = Vec::new();
let mut in_detail = false;
let mut in_remarks = false;
let mut remarks_text = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
let name = e.name();
match name.as_ref() {
b"event" => {
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"uid" => {
uid =
Some(String::from_utf8_lossy(&attr.value).into_owned());
}
b"type" => {
cot_type = Some(CotType::new(&String::from_utf8_lossy(
&attr.value,
)));
}
b"time" => {
time = Self::parse_time(&attr.value);
}
b"start" => {
start = Self::parse_time(&attr.value);
}
b"stale" => {
stale = Self::parse_time(&attr.value);
}
b"how" => {
how = String::from_utf8_lossy(&attr.value).into_owned();
}
_ => {}
}
}
}
b"point" => {
let mut lat = 0.0;
let mut lon = 0.0;
let mut hae = 0.0;
let mut ce = 9999999.0;
let mut le = 9999999.0;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"lat" => {
lat = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(0.0);
}
b"lon" => {
lon = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(0.0);
}
b"hae" => {
hae = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(0.0);
}
b"ce" => {
ce = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(9999999.0);
}
b"le" => {
le = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(9999999.0);
}
_ => {}
}
}
point = Some(CotPoint::with_full(lat, lon, hae, ce, le));
}
b"detail" => {
in_detail = true;
}
b"track" if in_detail => {
let mut course = 0.0;
let mut speed = 0.0;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"course" => {
course = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(0.0);
}
b"speed" => {
speed = String::from_utf8_lossy(&attr.value)
.parse()
.unwrap_or(0.0);
}
_ => {}
}
}
detail.track = Some(CotTrack { course, speed });
}
b"contact" if in_detail => {
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"callsign" {
detail.contact_callsign =
Some(String::from_utf8_lossy(&attr.value).into_owned());
}
}
}
b"remarks" if in_detail => {
in_remarks = true;
remarks_text.clear();
}
b"link" if in_detail => {
let mut link_uid = String::new();
let mut link_type = String::new();
let mut relation = String::new();
let mut link_remarks = None;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"uid" => {
link_uid =
String::from_utf8_lossy(&attr.value).into_owned();
}
b"type" => {
link_type =
String::from_utf8_lossy(&attr.value).into_owned();
}
b"relation" => {
relation =
String::from_utf8_lossy(&attr.value).into_owned();
}
b"remarks" => {
link_remarks =
Some(String::from_utf8_lossy(&attr.value).into_owned());
}
_ => {}
}
}
if !link_uid.is_empty() {
detail.links.push(CotLink {
uid: link_uid,
cot_type: link_type,
relation,
remarks: link_remarks,
});
}
}
_ => {}
}
}
Ok(Event::Text(ref e)) if in_remarks => {
remarks_text.push_str(&e.unescape().unwrap_or_default());
}
Ok(Event::End(ref e)) => match e.name().as_ref() {
b"detail" => in_detail = false,
b"remarks" => {
in_remarks = false;
if !remarks_text.is_empty() {
detail.remarks = Some(remarks_text.clone());
}
}
_ => {}
},
Ok(Event::Eof) => break,
Err(e) => {
return Err(CotError::XmlRead(format!(
"XML parse error at position {}: {:?}",
reader.buffer_position(),
e
)));
}
_ => {}
}
buf.clear();
}
let uid = uid.ok_or(CotError::MissingField("uid"))?;
let cot_type = cot_type.ok_or(CotError::MissingField("type"))?;
let point = point.ok_or(CotError::MissingField("point"))?;
let time = time.unwrap_or_else(Utc::now);
let start = start.unwrap_or(time);
let stale = stale.unwrap_or(time + Duration::minutes(5));
Ok(CotEvent {
version: "2.0".to_string(),
uid,
cot_type,
time,
start,
stale,
how,
point,
detail,
})
}
fn parse_time(value: &[u8]) -> Option<DateTime<Utc>> {
let s = String::from_utf8_lossy(value);
DateTime::parse_from_rfc3339(&s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
.or_else(|| {
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.fZ")
.ok()
.map(|ndt| ndt.and_utc())
})
.or_else(|| {
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%SZ")
.ok()
.map(|ndt| ndt.and_utc())
})
}
fn format_time(&self, time: &DateTime<Utc>) -> String {
time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
fn write_point(&self, writer: &mut Writer<Cursor<Vec<u8>>>) -> Result<(), CotError> {
let lat_str = self.point.lat.to_string();
let lon_str = self.point.lon.to_string();
let hae_str = self.point.hae.to_string();
let ce_str = self.point.ce.to_string();
let le_str = self.point.le.to_string();
let mut point_elem = BytesStart::new("point");
point_elem.push_attribute(("lat", lat_str.as_str()));
point_elem.push_attribute(("lon", lon_str.as_str()));
point_elem.push_attribute(("hae", hae_str.as_str()));
point_elem.push_attribute(("ce", ce_str.as_str()));
point_elem.push_attribute(("le", le_str.as_str()));
writer
.write_event(Event::Empty(point_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
Ok(())
}
fn write_detail(&self, writer: &mut Writer<Cursor<Vec<u8>>>) -> Result<(), CotError> {
writer
.write_event(Event::Start(BytesStart::new("detail")))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
if let Some(ref track) = self.detail.track {
let course_str = track.course.to_string();
let speed_str = track.speed.to_string();
let mut track_elem = BytesStart::new("track");
track_elem.push_attribute(("course", course_str.as_str()));
track_elem.push_attribute(("speed", speed_str.as_str()));
writer
.write_event(Event::Empty(track_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
}
if let Some(ref callsign) = self.detail.contact_callsign {
let mut contact_elem = BytesStart::new("contact");
contact_elem.push_attribute(("callsign", callsign.as_str()));
writer
.write_event(Event::Empty(contact_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
}
if let Some(ref group) = self.detail.group {
let mut group_elem = BytesStart::new("__group");
group_elem.push_attribute(("name", group.name.as_str()));
group_elem.push_attribute(("role", group.role.as_str()));
writer
.write_event(Event::Empty(group_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
}
if let Some(ref remarks) = self.detail.remarks {
writer
.write_event(Event::Start(BytesStart::new("remarks")))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
writer
.write_event(Event::Text(BytesText::new(remarks)))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("remarks")))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
}
if let Some(ref peat) = self.detail.peat_extension {
peat.write_xml(writer)?;
}
for link in &self.detail.links {
let mut link_elem = BytesStart::new("link");
link_elem.push_attribute(("uid", link.uid.as_str()));
link_elem.push_attribute(("type", link.cot_type.as_str()));
link_elem.push_attribute(("relation", link.relation.as_str()));
if let Some(ref remarks) = link.remarks {
link_elem.push_attribute(("remarks", remarks.as_str()));
}
writer
.write_event(Event::Empty(link_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
}
if let Some(ref priority) = self.detail.flow_priority {
let mut flow_elem = BytesStart::new("_flow-tags_");
flow_elem.push_attribute(("priority", priority.as_str()));
writer
.write_event(Event::Empty(flow_elem))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
}
writer
.write_event(Event::End(BytesEnd::new("detail")))
.map_err(|e| CotError::XmlWrite(e.to_string()))?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CotPoint {
pub lat: f64,
pub lon: f64,
pub hae: f64,
pub ce: f64,
pub le: f64,
}
impl CotPoint {
pub fn new(lat: f64, lon: f64) -> Self {
Self {
lat,
lon,
hae: 0.0,
ce: 9999999.0, le: 9999999.0, }
}
pub fn with_full(lat: f64, lon: f64, hae: f64, ce: f64, le: f64) -> Self {
Self {
lat,
lon,
hae,
ce,
le,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CotDetail {
pub track: Option<CotTrack>,
pub contact_callsign: Option<String>,
pub group: Option<CotGroup>,
pub remarks: Option<String>,
pub peat_extension: Option<PeatExtension>,
pub links: Vec<CotLink>,
pub flow_priority: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CotTrack {
pub course: f64,
pub speed: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CotGroup {
pub name: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CotLink {
pub uid: String,
pub cot_type: String,
pub relation: String,
pub remarks: Option<String>,
}
impl CotLink {
pub fn new(uid: &str, cot_type: &str, relation: CotRelation) -> Self {
Self {
uid: uid.to_string(),
cot_type: cot_type.to_string(),
relation: relation.as_str().to_string(),
remarks: None,
}
}
pub fn with_remarks(mut self, remarks: &str) -> Self {
self.remarks = Some(remarks.to_string());
self
}
}
#[derive(Debug, Default)]
pub struct CotEventBuilder {
uid: Option<String>,
cot_type: Option<CotType>,
time: Option<DateTime<Utc>>,
stale_duration: Duration,
how: String,
point: Option<CotPoint>,
detail: CotDetail,
}
impl CotEventBuilder {
pub fn new() -> Self {
Self {
uid: None,
cot_type: None,
time: None,
stale_duration: Duration::seconds(30),
how: "m-g".to_string(), point: None,
detail: CotDetail::default(),
}
}
pub fn uid(mut self, uid: &str) -> Self {
self.uid = Some(uid.to_string());
self
}
pub fn cot_type(mut self, cot_type: CotType) -> Self {
self.cot_type = Some(cot_type);
self
}
pub fn time(mut self, time: DateTime<Utc>) -> Self {
self.time = Some(time);
self
}
pub fn stale_duration(mut self, duration: Duration) -> Self {
self.stale_duration = duration;
self
}
pub fn how(mut self, how: &str) -> Self {
self.how = how.to_string();
self
}
pub fn point(mut self, point: CotPoint) -> Self {
self.point = Some(point);
self
}
pub fn track(mut self, course: f64, speed: f64) -> Self {
self.detail.track = Some(CotTrack { course, speed });
self
}
pub fn callsign(mut self, callsign: &str) -> Self {
self.detail.contact_callsign = Some(callsign.to_string());
self
}
pub fn group(mut self, name: &str, role: &str) -> Self {
self.detail.group = Some(CotGroup {
name: name.to_string(),
role: role.to_string(),
});
self
}
pub fn remarks(mut self, remarks: &str) -> Self {
self.detail.remarks = Some(remarks.to_string());
self
}
pub fn peat_extension(mut self, extension: PeatExtension) -> Self {
self.detail.peat_extension = Some(extension);
self
}
pub fn link(mut self, link: CotLink) -> Self {
self.detail.links.push(link);
self
}
pub fn flow_priority(mut self, priority: &str) -> Self {
self.detail.flow_priority = Some(priority.to_string());
self
}
pub fn build(self) -> Result<CotEvent, CotError> {
let uid = self.uid.ok_or(CotError::MissingField("uid"))?;
let cot_type = self.cot_type.ok_or(CotError::MissingField("cot_type"))?;
let point = self.point.ok_or(CotError::MissingField("point"))?;
let time = self.time.unwrap_or_else(Utc::now);
Ok(CotEvent {
version: "2.0".to_string(),
uid,
cot_type,
time,
start: time,
stale: time + self.stale_duration,
how: self.how,
point,
detail: self.detail,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CotError {
MissingField(&'static str),
XmlWrite(String),
XmlRead(String),
Encoding(String),
}
impl std::fmt::Display for CotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingField(field) => write!(f, "Missing required field: {}", field),
Self::XmlWrite(msg) => write!(f, "XML write error: {}", msg),
Self::XmlRead(msg) => write!(f, "XML read error: {}", msg),
Self::Encoding(msg) => write!(f, "Encoding error: {}", msg),
}
}
}
impl std::error::Error for CotError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cot_event_builder() {
let event = CotEvent::builder()
.uid("TRACK-001")
.cot_type(CotType::new("a-f-G-E-S"))
.point(CotPoint::new(33.7749, -84.3958))
.remarks("Test track")
.build()
.unwrap();
assert_eq!(event.uid, "TRACK-001");
assert_eq!(event.cot_type.as_str(), "a-f-G-E-S");
assert_eq!(event.point.lat, 33.7749);
}
#[test]
fn test_cot_event_missing_uid() {
let result = CotEvent::builder()
.cot_type(CotType::new("a-f-G"))
.point(CotPoint::new(0.0, 0.0))
.build();
assert!(matches!(result, Err(CotError::MissingField("uid"))));
}
#[test]
fn test_cot_event_to_xml() {
let event = CotEvent::builder()
.uid("TEST-001")
.cot_type(CotType::new("a-f-G-E-S"))
.point(CotPoint::new(33.7749, -84.3958))
.remarks("Test event")
.build()
.unwrap();
let xml = event.to_xml().unwrap();
assert!(xml.contains("<?xml version=\"1.0\""));
assert!(xml.contains("uid=\"TEST-001\""));
assert!(xml.contains("type=\"a-f-G-E-S\""));
assert!(xml.contains("lat=\"33.7749\""));
assert!(xml.contains("<remarks>Test event</remarks>"));
}
#[test]
fn test_cot_event_with_track() {
let event = CotEvent::builder()
.uid("TRACK-001")
.cot_type(CotType::new("a-f-G-E-S"))
.point(CotPoint::new(33.7749, -84.3958))
.track(45.0, 5.0)
.build()
.unwrap();
let xml = event.to_xml().unwrap();
assert!(xml.contains("course=\"45\""));
assert!(xml.contains("speed=\"5\""));
}
#[test]
fn test_cot_event_with_links() {
let event = CotEvent::builder()
.uid("TRACK-001")
.cot_type(CotType::new("a-f-G-E-S"))
.point(CotPoint::new(33.7749, -84.3958))
.link(
CotLink::new("Alpha-Team", "a-f-G-U-C", CotRelation::Parent)
.with_remarks("parent-cell"),
)
.build()
.unwrap();
let xml = event.to_xml().unwrap();
assert!(xml.contains("relation=\"p-p\""));
assert!(xml.contains("remarks=\"parent-cell\""));
}
#[test]
fn test_cot_point_defaults() {
let point = CotPoint::new(0.0, 0.0);
assert_eq!(point.hae, 0.0);
assert_eq!(point.ce, 9999999.0);
assert_eq!(point.le, 9999999.0);
}
#[test]
fn test_cot_link_creation() {
let link = CotLink::new("target-uid", "a-f-G-U-C", CotRelation::Observing);
assert_eq!(link.relation, "o-o");
}
#[test]
fn test_cot_event_with_group() {
let event = CotEvent::builder()
.uid("PLATFORM-001")
.cot_type(CotType::new("a-f-G-U-C"))
.point(CotPoint::new(33.7749, -84.3958))
.group("Alpha-Team", "Team Member")
.build()
.unwrap();
let xml = event.to_xml().unwrap();
assert!(xml.contains("__group"));
assert!(xml.contains("name=\"Alpha-Team\""));
}
#[test]
fn test_cot_event_from_xml_basic() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<event version="2.0" uid="TEST-001" type="a-f-G-E-S"
time="2025-12-08T14:10:00Z" start="2025-12-08T14:10:00Z"
stale="2025-12-08T14:15:00Z" how="m-g">
<point lat="33.7749" lon="-84.3958" hae="10.0" ce="5.0" le="3.0"/>
<detail>
<remarks>Test event</remarks>
</detail>
</event>"#;
let event = CotEvent::from_xml(xml).unwrap();
assert_eq!(event.uid, "TEST-001");
assert_eq!(event.cot_type.as_str(), "a-f-G-E-S");
assert_eq!(event.how, "m-g");
assert_eq!(event.point.lat, 33.7749);
assert_eq!(event.point.lon, -84.3958);
assert_eq!(event.point.hae, 10.0);
assert_eq!(event.point.ce, 5.0);
assert_eq!(event.point.le, 3.0);
assert_eq!(event.detail.remarks.as_deref(), Some("Test event"));
}
#[test]
fn test_cot_event_from_xml_roundtrip() {
let original = CotEvent::builder()
.uid("ROUNDTRIP-001")
.cot_type(CotType::new("a-f-G-U-C"))
.point(CotPoint::with_full(38.8977, -77.0365, 50.0, 10.0, 5.0))
.remarks("Roundtrip test")
.track(90.0, 5.5)
.build()
.unwrap();
let xml = original.to_xml().unwrap();
let parsed = CotEvent::from_xml(&xml).unwrap();
assert_eq!(parsed.uid, original.uid);
assert_eq!(parsed.cot_type.as_str(), original.cot_type.as_str());
assert_eq!(parsed.point.lat, original.point.lat);
assert_eq!(parsed.point.lon, original.point.lon);
assert_eq!(parsed.detail.remarks, original.detail.remarks);
assert!(parsed.detail.track.is_some());
assert_eq!(parsed.detail.track.as_ref().unwrap().course, 90.0);
assert_eq!(parsed.detail.track.as_ref().unwrap().speed, 5.5);
}
#[test]
fn test_cot_event_from_xml_mission_task() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<event uid="MISSION-001" type="t-x-m-c-c" time="2025-12-08T14:05:00Z"
start="2025-12-08T14:05:00Z" stale="2025-12-08T15:05:00Z" how="h-g-i-g-o">
<point lat="33.7756" lon="-84.3963" hae="0" ce="100" le="100"/>
<detail>
<remarks>Track POI within designated area</remarks>
</detail>
</event>"#;
let event = CotEvent::from_xml(xml).unwrap();
assert_eq!(event.uid, "MISSION-001");
assert_eq!(event.cot_type.as_str(), "t-x-m-c-c");
assert_eq!(event.how, "h-g-i-g-o");
assert_eq!(event.point.lat, 33.7756);
assert_eq!(event.point.lon, -84.3963);
}
#[test]
fn test_cot_event_from_xml_with_contact() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<event uid="ALPHA-3" type="a-f-G-U-C" time="2025-12-08T14:00:00Z"
start="2025-12-08T14:00:00Z" stale="2025-12-08T14:01:00Z" how="m-g">
<point lat="38.0" lon="-77.0" hae="0" ce="10" le="10"/>
<detail>
<contact callsign="Alpha-3"/>
</detail>
</event>"#;
let event = CotEvent::from_xml(xml).unwrap();
assert_eq!(event.detail.contact_callsign.as_deref(), Some("Alpha-3"));
}
#[test]
fn test_cot_event_from_xml_missing_uid() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<event type="a-f-G" time="2025-12-08T14:00:00Z"
start="2025-12-08T14:00:00Z" stale="2025-12-08T14:01:00Z" how="m-g">
<point lat="0" lon="0" hae="0" ce="10" le="10"/>
<detail/>
</event>"#;
let result = CotEvent::from_xml(xml);
assert!(matches!(result, Err(CotError::MissingField("uid"))));
}
#[test]
fn test_cot_event_from_xml_missing_point() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<event uid="TEST" type="a-f-G" time="2025-12-08T14:00:00Z"
start="2025-12-08T14:00:00Z" stale="2025-12-08T14:01:00Z" how="m-g">
<detail/>
</event>"#;
let result = CotEvent::from_xml(xml);
assert!(matches!(result, Err(CotError::MissingField("point"))));
}
}