Skip to main content

peat_protocol/cot/
event.rs

1//! CoT Event structure and XML encoding
2//!
3//! Implements the Cursor-on-Target XML schema for TAK integration.
4
5use chrono::{DateTime, Duration, Utc};
6use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
7use quick_xml::Reader;
8use quick_xml::Writer;
9use serde::{Deserialize, Serialize};
10use std::io::Cursor;
11
12use super::peat_extension::PeatExtension;
13use super::type_mapper::{CotRelation, CotType};
14
15/// CoT Event - the root element of a Cursor-on-Target message
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct CotEvent {
18    /// CoT version (default "2.0")
19    pub version: String,
20    /// Unique identifier for this event
21    pub uid: String,
22    /// CoT type code (MIL-STD-2525 derived)
23    pub cot_type: CotType,
24    /// Event generation time
25    pub time: DateTime<Utc>,
26    /// Event validity start time
27    pub start: DateTime<Utc>,
28    /// Event stale time (when it expires)
29    pub stale: DateTime<Utc>,
30    /// How the event was generated (m-g = machine-generated)
31    pub how: String,
32    /// Position information
33    pub point: CotPoint,
34    /// Detail information
35    pub detail: CotDetail,
36}
37
38impl CotEvent {
39    /// Create a new builder for CotEvent
40    pub fn builder() -> CotEventBuilder {
41        CotEventBuilder::new()
42    }
43
44    /// Encode the event as XML string
45    pub fn to_xml(&self) -> Result<String, CotError> {
46        let mut writer = Writer::new(Cursor::new(Vec::new()));
47
48        // XML declaration
49        writer
50            .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
51            .map_err(|e| CotError::XmlWrite(e.to_string()))?;
52
53        // Event element
54        let time_str = self.format_time(&self.time);
55        let start_str = self.format_time(&self.start);
56        let stale_str = self.format_time(&self.stale);
57
58        let mut event_elem = BytesStart::new("event");
59        event_elem.push_attribute(("version", self.version.as_str()));
60        event_elem.push_attribute(("uid", self.uid.as_str()));
61        event_elem.push_attribute(("type", self.cot_type.as_str()));
62        event_elem.push_attribute(("time", time_str.as_str()));
63        event_elem.push_attribute(("start", start_str.as_str()));
64        event_elem.push_attribute(("stale", stale_str.as_str()));
65        event_elem.push_attribute(("how", self.how.as_str()));
66
67        writer
68            .write_event(Event::Start(event_elem))
69            .map_err(|e| CotError::XmlWrite(e.to_string()))?;
70
71        // Point element
72        self.write_point(&mut writer)?;
73
74        // Detail element
75        self.write_detail(&mut writer)?;
76
77        // Close event
78        writer
79            .write_event(Event::End(BytesEnd::new("event")))
80            .map_err(|e| CotError::XmlWrite(e.to_string()))?;
81
82        let result = writer.into_inner().into_inner();
83        String::from_utf8(result).map_err(|e| CotError::Encoding(e.to_string()))
84    }
85
86    /// Parse a CoT event from XML string (Issue #318)
87    ///
88    /// Supports parsing mission task events and other CoT messages from TAK Server.
89    pub fn from_xml(xml: &str) -> Result<Self, CotError> {
90        let mut reader = Reader::from_str(xml);
91        reader.config_mut().trim_text(true);
92
93        let mut uid = None;
94        let mut cot_type = None;
95        let mut time = None;
96        let mut start = None;
97        let mut stale = None;
98        let mut how = String::from("m-g");
99        let mut point = None;
100        let mut detail = CotDetail::default();
101
102        let mut buf = Vec::new();
103        let mut in_detail = false;
104        let mut in_remarks = false;
105        let mut remarks_text = String::new();
106
107        loop {
108            match reader.read_event_into(&mut buf) {
109                Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
110                    let name = e.name();
111                    match name.as_ref() {
112                        b"event" => {
113                            // Parse event attributes
114                            for attr in e.attributes().flatten() {
115                                match attr.key.as_ref() {
116                                    b"uid" => {
117                                        uid =
118                                            Some(String::from_utf8_lossy(&attr.value).into_owned());
119                                    }
120                                    b"type" => {
121                                        cot_type = Some(CotType::new(&String::from_utf8_lossy(
122                                            &attr.value,
123                                        )));
124                                    }
125                                    b"time" => {
126                                        time = Self::parse_time(&attr.value);
127                                    }
128                                    b"start" => {
129                                        start = Self::parse_time(&attr.value);
130                                    }
131                                    b"stale" => {
132                                        stale = Self::parse_time(&attr.value);
133                                    }
134                                    b"how" => {
135                                        how = String::from_utf8_lossy(&attr.value).into_owned();
136                                    }
137                                    _ => {}
138                                }
139                            }
140                        }
141                        b"point" => {
142                            let mut lat = 0.0;
143                            let mut lon = 0.0;
144                            let mut hae = 0.0;
145                            let mut ce = 9999999.0;
146                            let mut le = 9999999.0;
147
148                            for attr in e.attributes().flatten() {
149                                match attr.key.as_ref() {
150                                    b"lat" => {
151                                        lat = String::from_utf8_lossy(&attr.value)
152                                            .parse()
153                                            .unwrap_or(0.0);
154                                    }
155                                    b"lon" => {
156                                        lon = String::from_utf8_lossy(&attr.value)
157                                            .parse()
158                                            .unwrap_or(0.0);
159                                    }
160                                    b"hae" => {
161                                        hae = String::from_utf8_lossy(&attr.value)
162                                            .parse()
163                                            .unwrap_or(0.0);
164                                    }
165                                    b"ce" => {
166                                        ce = String::from_utf8_lossy(&attr.value)
167                                            .parse()
168                                            .unwrap_or(9999999.0);
169                                    }
170                                    b"le" => {
171                                        le = String::from_utf8_lossy(&attr.value)
172                                            .parse()
173                                            .unwrap_or(9999999.0);
174                                    }
175                                    _ => {}
176                                }
177                            }
178                            point = Some(CotPoint::with_full(lat, lon, hae, ce, le));
179                        }
180                        b"detail" => {
181                            in_detail = true;
182                        }
183                        b"track" if in_detail => {
184                            let mut course = 0.0;
185                            let mut speed = 0.0;
186                            for attr in e.attributes().flatten() {
187                                match attr.key.as_ref() {
188                                    b"course" => {
189                                        course = String::from_utf8_lossy(&attr.value)
190                                            .parse()
191                                            .unwrap_or(0.0);
192                                    }
193                                    b"speed" => {
194                                        speed = String::from_utf8_lossy(&attr.value)
195                                            .parse()
196                                            .unwrap_or(0.0);
197                                    }
198                                    _ => {}
199                                }
200                            }
201                            detail.track = Some(CotTrack { course, speed });
202                        }
203                        b"contact" if in_detail => {
204                            for attr in e.attributes().flatten() {
205                                if attr.key.as_ref() == b"callsign" {
206                                    detail.contact_callsign =
207                                        Some(String::from_utf8_lossy(&attr.value).into_owned());
208                                }
209                            }
210                        }
211                        b"remarks" if in_detail => {
212                            in_remarks = true;
213                            remarks_text.clear();
214                        }
215                        b"link" if in_detail => {
216                            let mut link_uid = String::new();
217                            let mut link_type = String::new();
218                            let mut relation = String::new();
219                            let mut link_remarks = None;
220
221                            for attr in e.attributes().flatten() {
222                                match attr.key.as_ref() {
223                                    b"uid" => {
224                                        link_uid =
225                                            String::from_utf8_lossy(&attr.value).into_owned();
226                                    }
227                                    b"type" => {
228                                        link_type =
229                                            String::from_utf8_lossy(&attr.value).into_owned();
230                                    }
231                                    b"relation" => {
232                                        relation =
233                                            String::from_utf8_lossy(&attr.value).into_owned();
234                                    }
235                                    b"remarks" => {
236                                        link_remarks =
237                                            Some(String::from_utf8_lossy(&attr.value).into_owned());
238                                    }
239                                    _ => {}
240                                }
241                            }
242                            if !link_uid.is_empty() {
243                                detail.links.push(CotLink {
244                                    uid: link_uid,
245                                    cot_type: link_type,
246                                    relation,
247                                    remarks: link_remarks,
248                                });
249                            }
250                        }
251                        _ => {}
252                    }
253                }
254                Ok(Event::Text(ref e)) if in_remarks => {
255                    remarks_text.push_str(&e.unescape().unwrap_or_default());
256                }
257                Ok(Event::End(ref e)) => match e.name().as_ref() {
258                    b"detail" => in_detail = false,
259                    b"remarks" => {
260                        in_remarks = false;
261                        if !remarks_text.is_empty() {
262                            detail.remarks = Some(remarks_text.clone());
263                        }
264                    }
265                    _ => {}
266                },
267                Ok(Event::Eof) => break,
268                Err(e) => {
269                    return Err(CotError::XmlRead(format!(
270                        "XML parse error at position {}: {:?}",
271                        reader.buffer_position(),
272                        e
273                    )));
274                }
275                _ => {}
276            }
277            buf.clear();
278        }
279
280        let uid = uid.ok_or(CotError::MissingField("uid"))?;
281        let cot_type = cot_type.ok_or(CotError::MissingField("type"))?;
282        let point = point.ok_or(CotError::MissingField("point"))?;
283        let time = time.unwrap_or_else(Utc::now);
284        let start = start.unwrap_or(time);
285        let stale = stale.unwrap_or(time + Duration::minutes(5));
286
287        Ok(CotEvent {
288            version: "2.0".to_string(),
289            uid,
290            cot_type,
291            time,
292            start,
293            stale,
294            how,
295            point,
296            detail,
297        })
298    }
299
300    /// Parse ISO 8601 timestamp from bytes
301    fn parse_time(value: &[u8]) -> Option<DateTime<Utc>> {
302        let s = String::from_utf8_lossy(value);
303        DateTime::parse_from_rfc3339(&s)
304            .ok()
305            .map(|dt| dt.with_timezone(&Utc))
306            .or_else(|| {
307                // Try alternative format without timezone
308                chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.fZ")
309                    .ok()
310                    .map(|ndt| ndt.and_utc())
311            })
312            .or_else(|| {
313                // Try another common TAK format
314                chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%SZ")
315                    .ok()
316                    .map(|ndt| ndt.and_utc())
317            })
318    }
319
320    fn format_time(&self, time: &DateTime<Utc>) -> String {
321        time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
322    }
323
324    fn write_point(&self, writer: &mut Writer<Cursor<Vec<u8>>>) -> Result<(), CotError> {
325        let lat_str = self.point.lat.to_string();
326        let lon_str = self.point.lon.to_string();
327        let hae_str = self.point.hae.to_string();
328        let ce_str = self.point.ce.to_string();
329        let le_str = self.point.le.to_string();
330
331        let mut point_elem = BytesStart::new("point");
332        point_elem.push_attribute(("lat", lat_str.as_str()));
333        point_elem.push_attribute(("lon", lon_str.as_str()));
334        point_elem.push_attribute(("hae", hae_str.as_str()));
335        point_elem.push_attribute(("ce", ce_str.as_str()));
336        point_elem.push_attribute(("le", le_str.as_str()));
337
338        writer
339            .write_event(Event::Empty(point_elem))
340            .map_err(|e| CotError::XmlWrite(e.to_string()))?;
341
342        Ok(())
343    }
344
345    fn write_detail(&self, writer: &mut Writer<Cursor<Vec<u8>>>) -> Result<(), CotError> {
346        writer
347            .write_event(Event::Start(BytesStart::new("detail")))
348            .map_err(|e| CotError::XmlWrite(e.to_string()))?;
349
350        // Track element (if present)
351        if let Some(ref track) = self.detail.track {
352            let course_str = track.course.to_string();
353            let speed_str = track.speed.to_string();
354
355            let mut track_elem = BytesStart::new("track");
356            track_elem.push_attribute(("course", course_str.as_str()));
357            track_elem.push_attribute(("speed", speed_str.as_str()));
358
359            writer
360                .write_event(Event::Empty(track_elem))
361                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
362        }
363
364        // Contact element (if present)
365        if let Some(ref callsign) = self.detail.contact_callsign {
366            let mut contact_elem = BytesStart::new("contact");
367            contact_elem.push_attribute(("callsign", callsign.as_str()));
368
369            writer
370                .write_event(Event::Empty(contact_elem))
371                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
372        }
373
374        // Group element (if present)
375        if let Some(ref group) = self.detail.group {
376            let mut group_elem = BytesStart::new("__group");
377            group_elem.push_attribute(("name", group.name.as_str()));
378            group_elem.push_attribute(("role", group.role.as_str()));
379
380            writer
381                .write_event(Event::Empty(group_elem))
382                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
383        }
384
385        // Remarks element (if present)
386        if let Some(ref remarks) = self.detail.remarks {
387            writer
388                .write_event(Event::Start(BytesStart::new("remarks")))
389                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
390            writer
391                .write_event(Event::Text(BytesText::new(remarks)))
392                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
393            writer
394                .write_event(Event::End(BytesEnd::new("remarks")))
395                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
396        }
397
398        // Peat extension (if present)
399        if let Some(ref peat) = self.detail.peat_extension {
400            peat.write_xml(writer)?;
401        }
402
403        // Link elements
404        for link in &self.detail.links {
405            let mut link_elem = BytesStart::new("link");
406            link_elem.push_attribute(("uid", link.uid.as_str()));
407            link_elem.push_attribute(("type", link.cot_type.as_str()));
408            link_elem.push_attribute(("relation", link.relation.as_str()));
409            if let Some(ref remarks) = link.remarks {
410                link_elem.push_attribute(("remarks", remarks.as_str()));
411            }
412
413            writer
414                .write_event(Event::Empty(link_elem))
415                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
416        }
417
418        // Flow tags (if present)
419        if let Some(ref priority) = self.detail.flow_priority {
420            let mut flow_elem = BytesStart::new("_flow-tags_");
421            flow_elem.push_attribute(("priority", priority.as_str()));
422
423            writer
424                .write_event(Event::Empty(flow_elem))
425                .map_err(|e| CotError::XmlWrite(e.to_string()))?;
426        }
427
428        writer
429            .write_event(Event::End(BytesEnd::new("detail")))
430            .map_err(|e| CotError::XmlWrite(e.to_string()))?;
431
432        Ok(())
433    }
434}
435
436/// Point element containing WGS84 coordinates
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
438pub struct CotPoint {
439    /// Latitude (WGS84)
440    pub lat: f64,
441    /// Longitude (WGS84)
442    pub lon: f64,
443    /// Height Above Ellipsoid (meters)
444    pub hae: f64,
445    /// Circular Error (meters) - horizontal accuracy
446    pub ce: f64,
447    /// Linear Error (meters) - vertical accuracy
448    pub le: f64,
449}
450
451impl CotPoint {
452    /// Create a new point with default accuracy values
453    pub fn new(lat: f64, lon: f64) -> Self {
454        Self {
455            lat,
456            lon,
457            hae: 0.0,
458            ce: 9999999.0, // Unknown accuracy
459            le: 9999999.0, // Unknown accuracy
460        }
461    }
462
463    /// Create a point with full position data
464    pub fn with_full(lat: f64, lon: f64, hae: f64, ce: f64, le: f64) -> Self {
465        Self {
466            lat,
467            lon,
468            hae,
469            ce,
470            le,
471        }
472    }
473}
474
475/// Detail element containing supplementary information
476#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
477pub struct CotDetail {
478    /// Track information (course/speed)
479    pub track: Option<CotTrack>,
480    /// Contact callsign
481    pub contact_callsign: Option<String>,
482    /// Group membership
483    pub group: Option<CotGroup>,
484    /// Remarks/description
485    pub remarks: Option<String>,
486    /// Peat custom extension
487    pub peat_extension: Option<PeatExtension>,
488    /// Related entity links
489    pub links: Vec<CotLink>,
490    /// Flow priority for QoS
491    pub flow_priority: Option<String>,
492}
493
494/// Track element with course and speed
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
496pub struct CotTrack {
497    /// Course/bearing in degrees
498    pub course: f64,
499    /// Speed in meters per second
500    pub speed: f64,
501}
502
503/// Group membership element
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct CotGroup {
506    /// Group name
507    pub name: String,
508    /// Role within group
509    pub role: String,
510}
511
512/// Link to related entity
513#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
514pub struct CotLink {
515    /// UID of linked entity
516    pub uid: String,
517    /// CoT type of linked entity
518    pub cot_type: String,
519    /// Relationship type
520    pub relation: String,
521    /// Optional remarks
522    pub remarks: Option<String>,
523}
524
525impl CotLink {
526    /// Create a new link
527    pub fn new(uid: &str, cot_type: &str, relation: CotRelation) -> Self {
528        Self {
529            uid: uid.to_string(),
530            cot_type: cot_type.to_string(),
531            relation: relation.as_str().to_string(),
532            remarks: None,
533        }
534    }
535
536    /// Add remarks
537    pub fn with_remarks(mut self, remarks: &str) -> Self {
538        self.remarks = Some(remarks.to_string());
539        self
540    }
541}
542
543/// Builder for CotEvent
544#[derive(Debug, Default)]
545pub struct CotEventBuilder {
546    uid: Option<String>,
547    cot_type: Option<CotType>,
548    time: Option<DateTime<Utc>>,
549    stale_duration: Duration,
550    how: String,
551    point: Option<CotPoint>,
552    detail: CotDetail,
553}
554
555impl CotEventBuilder {
556    /// Create a new builder
557    pub fn new() -> Self {
558        Self {
559            uid: None,
560            cot_type: None,
561            time: None,
562            stale_duration: Duration::seconds(30),
563            how: "m-g".to_string(), // machine-generated
564            point: None,
565            detail: CotDetail::default(),
566        }
567    }
568
569    /// Set the UID
570    pub fn uid(mut self, uid: &str) -> Self {
571        self.uid = Some(uid.to_string());
572        self
573    }
574
575    /// Set the CoT type
576    pub fn cot_type(mut self, cot_type: CotType) -> Self {
577        self.cot_type = Some(cot_type);
578        self
579    }
580
581    /// Set the timestamp
582    pub fn time(mut self, time: DateTime<Utc>) -> Self {
583        self.time = Some(time);
584        self
585    }
586
587    /// Set stale duration
588    pub fn stale_duration(mut self, duration: Duration) -> Self {
589        self.stale_duration = duration;
590        self
591    }
592
593    /// Set how the event was generated
594    pub fn how(mut self, how: &str) -> Self {
595        self.how = how.to_string();
596        self
597    }
598
599    /// Set the point
600    pub fn point(mut self, point: CotPoint) -> Self {
601        self.point = Some(point);
602        self
603    }
604
605    /// Set track information
606    pub fn track(mut self, course: f64, speed: f64) -> Self {
607        self.detail.track = Some(CotTrack { course, speed });
608        self
609    }
610
611    /// Set contact callsign
612    pub fn callsign(mut self, callsign: &str) -> Self {
613        self.detail.contact_callsign = Some(callsign.to_string());
614        self
615    }
616
617    /// Set group membership
618    pub fn group(mut self, name: &str, role: &str) -> Self {
619        self.detail.group = Some(CotGroup {
620            name: name.to_string(),
621            role: role.to_string(),
622        });
623        self
624    }
625
626    /// Set remarks
627    pub fn remarks(mut self, remarks: &str) -> Self {
628        self.detail.remarks = Some(remarks.to_string());
629        self
630    }
631
632    /// Set Peat extension
633    pub fn peat_extension(mut self, extension: PeatExtension) -> Self {
634        self.detail.peat_extension = Some(extension);
635        self
636    }
637
638    /// Add a link
639    pub fn link(mut self, link: CotLink) -> Self {
640        self.detail.links.push(link);
641        self
642    }
643
644    /// Set flow priority (QoS)
645    pub fn flow_priority(mut self, priority: &str) -> Self {
646        self.detail.flow_priority = Some(priority.to_string());
647        self
648    }
649
650    /// Build the CotEvent
651    pub fn build(self) -> Result<CotEvent, CotError> {
652        let uid = self.uid.ok_or(CotError::MissingField("uid"))?;
653        let cot_type = self.cot_type.ok_or(CotError::MissingField("cot_type"))?;
654        let point = self.point.ok_or(CotError::MissingField("point"))?;
655        let time = self.time.unwrap_or_else(Utc::now);
656
657        Ok(CotEvent {
658            version: "2.0".to_string(),
659            uid,
660            cot_type,
661            time,
662            start: time,
663            stale: time + self.stale_duration,
664            how: self.how,
665            point,
666            detail: self.detail,
667        })
668    }
669}
670
671/// Errors during CoT encoding/decoding
672#[derive(Debug, Clone, PartialEq)]
673pub enum CotError {
674    /// Missing required field
675    MissingField(&'static str),
676    /// XML writing error
677    XmlWrite(String),
678    /// XML reading/parsing error
679    XmlRead(String),
680    /// Encoding error
681    Encoding(String),
682}
683
684impl std::fmt::Display for CotError {
685    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686        match self {
687            Self::MissingField(field) => write!(f, "Missing required field: {}", field),
688            Self::XmlWrite(msg) => write!(f, "XML write error: {}", msg),
689            Self::XmlRead(msg) => write!(f, "XML read error: {}", msg),
690            Self::Encoding(msg) => write!(f, "Encoding error: {}", msg),
691        }
692    }
693}
694
695impl std::error::Error for CotError {}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_cot_event_builder() {
703        let event = CotEvent::builder()
704            .uid("TRACK-001")
705            .cot_type(CotType::new("a-f-G-E-S"))
706            .point(CotPoint::new(33.7749, -84.3958))
707            .remarks("Test track")
708            .build()
709            .unwrap();
710
711        assert_eq!(event.uid, "TRACK-001");
712        assert_eq!(event.cot_type.as_str(), "a-f-G-E-S");
713        assert_eq!(event.point.lat, 33.7749);
714    }
715
716    #[test]
717    fn test_cot_event_missing_uid() {
718        let result = CotEvent::builder()
719            .cot_type(CotType::new("a-f-G"))
720            .point(CotPoint::new(0.0, 0.0))
721            .build();
722
723        assert!(matches!(result, Err(CotError::MissingField("uid"))));
724    }
725
726    #[test]
727    fn test_cot_event_to_xml() {
728        let event = CotEvent::builder()
729            .uid("TEST-001")
730            .cot_type(CotType::new("a-f-G-E-S"))
731            .point(CotPoint::new(33.7749, -84.3958))
732            .remarks("Test event")
733            .build()
734            .unwrap();
735
736        let xml = event.to_xml().unwrap();
737
738        assert!(xml.contains("<?xml version=\"1.0\""));
739        assert!(xml.contains("uid=\"TEST-001\""));
740        assert!(xml.contains("type=\"a-f-G-E-S\""));
741        assert!(xml.contains("lat=\"33.7749\""));
742        assert!(xml.contains("<remarks>Test event</remarks>"));
743    }
744
745    #[test]
746    fn test_cot_event_with_track() {
747        let event = CotEvent::builder()
748            .uid("TRACK-001")
749            .cot_type(CotType::new("a-f-G-E-S"))
750            .point(CotPoint::new(33.7749, -84.3958))
751            .track(45.0, 5.0)
752            .build()
753            .unwrap();
754
755        let xml = event.to_xml().unwrap();
756        assert!(xml.contains("course=\"45\""));
757        assert!(xml.contains("speed=\"5\""));
758    }
759
760    #[test]
761    fn test_cot_event_with_links() {
762        let event = CotEvent::builder()
763            .uid("TRACK-001")
764            .cot_type(CotType::new("a-f-G-E-S"))
765            .point(CotPoint::new(33.7749, -84.3958))
766            .link(
767                CotLink::new("Alpha-Team", "a-f-G-U-C", CotRelation::Parent)
768                    .with_remarks("parent-cell"),
769            )
770            .build()
771            .unwrap();
772
773        let xml = event.to_xml().unwrap();
774        assert!(xml.contains("relation=\"p-p\""));
775        assert!(xml.contains("remarks=\"parent-cell\""));
776    }
777
778    #[test]
779    fn test_cot_point_defaults() {
780        let point = CotPoint::new(0.0, 0.0);
781        assert_eq!(point.hae, 0.0);
782        assert_eq!(point.ce, 9999999.0);
783        assert_eq!(point.le, 9999999.0);
784    }
785
786    #[test]
787    fn test_cot_link_creation() {
788        let link = CotLink::new("target-uid", "a-f-G-U-C", CotRelation::Observing);
789        assert_eq!(link.relation, "o-o");
790    }
791
792    #[test]
793    fn test_cot_event_with_group() {
794        let event = CotEvent::builder()
795            .uid("PLATFORM-001")
796            .cot_type(CotType::new("a-f-G-U-C"))
797            .point(CotPoint::new(33.7749, -84.3958))
798            .group("Alpha-Team", "Team Member")
799            .build()
800            .unwrap();
801
802        let xml = event.to_xml().unwrap();
803        assert!(xml.contains("__group"));
804        assert!(xml.contains("name=\"Alpha-Team\""));
805    }
806
807    // =========================================================================
808    // from_xml() tests (Issue #318)
809    // =========================================================================
810
811    #[test]
812    fn test_cot_event_from_xml_basic() {
813        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
814            <event version="2.0" uid="TEST-001" type="a-f-G-E-S"
815                   time="2025-12-08T14:10:00Z" start="2025-12-08T14:10:00Z"
816                   stale="2025-12-08T14:15:00Z" how="m-g">
817                <point lat="33.7749" lon="-84.3958" hae="10.0" ce="5.0" le="3.0"/>
818                <detail>
819                    <remarks>Test event</remarks>
820                </detail>
821            </event>"#;
822
823        let event = CotEvent::from_xml(xml).unwrap();
824
825        assert_eq!(event.uid, "TEST-001");
826        assert_eq!(event.cot_type.as_str(), "a-f-G-E-S");
827        assert_eq!(event.how, "m-g");
828        assert_eq!(event.point.lat, 33.7749);
829        assert_eq!(event.point.lon, -84.3958);
830        assert_eq!(event.point.hae, 10.0);
831        assert_eq!(event.point.ce, 5.0);
832        assert_eq!(event.point.le, 3.0);
833        assert_eq!(event.detail.remarks.as_deref(), Some("Test event"));
834    }
835
836    #[test]
837    fn test_cot_event_from_xml_roundtrip() {
838        // Create an event, serialize to XML, parse back
839        let original = CotEvent::builder()
840            .uid("ROUNDTRIP-001")
841            .cot_type(CotType::new("a-f-G-U-C"))
842            .point(CotPoint::with_full(38.8977, -77.0365, 50.0, 10.0, 5.0))
843            .remarks("Roundtrip test")
844            .track(90.0, 5.5)
845            .build()
846            .unwrap();
847
848        let xml = original.to_xml().unwrap();
849        let parsed = CotEvent::from_xml(&xml).unwrap();
850
851        assert_eq!(parsed.uid, original.uid);
852        assert_eq!(parsed.cot_type.as_str(), original.cot_type.as_str());
853        assert_eq!(parsed.point.lat, original.point.lat);
854        assert_eq!(parsed.point.lon, original.point.lon);
855        assert_eq!(parsed.detail.remarks, original.detail.remarks);
856        assert!(parsed.detail.track.is_some());
857        assert_eq!(parsed.detail.track.as_ref().unwrap().course, 90.0);
858        assert_eq!(parsed.detail.track.as_ref().unwrap().speed, 5.5);
859    }
860
861    #[test]
862    fn test_cot_event_from_xml_mission_task() {
863        // Test parsing a mission task CoT event (t-x-m-c-c type)
864        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
865            <event uid="MISSION-001" type="t-x-m-c-c" time="2025-12-08T14:05:00Z"
866                   start="2025-12-08T14:05:00Z" stale="2025-12-08T15:05:00Z" how="h-g-i-g-o">
867                <point lat="33.7756" lon="-84.3963" hae="0" ce="100" le="100"/>
868                <detail>
869                    <remarks>Track POI within designated area</remarks>
870                </detail>
871            </event>"#;
872
873        let event = CotEvent::from_xml(xml).unwrap();
874
875        assert_eq!(event.uid, "MISSION-001");
876        assert_eq!(event.cot_type.as_str(), "t-x-m-c-c");
877        assert_eq!(event.how, "h-g-i-g-o");
878        assert_eq!(event.point.lat, 33.7756);
879        assert_eq!(event.point.lon, -84.3963);
880    }
881
882    #[test]
883    fn test_cot_event_from_xml_with_contact() {
884        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
885            <event uid="ALPHA-3" type="a-f-G-U-C" time="2025-12-08T14:00:00Z"
886                   start="2025-12-08T14:00:00Z" stale="2025-12-08T14:01:00Z" how="m-g">
887                <point lat="38.0" lon="-77.0" hae="0" ce="10" le="10"/>
888                <detail>
889                    <contact callsign="Alpha-3"/>
890                </detail>
891            </event>"#;
892
893        let event = CotEvent::from_xml(xml).unwrap();
894
895        assert_eq!(event.detail.contact_callsign.as_deref(), Some("Alpha-3"));
896    }
897
898    #[test]
899    fn test_cot_event_from_xml_missing_uid() {
900        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
901            <event type="a-f-G" time="2025-12-08T14:00:00Z"
902                   start="2025-12-08T14:00:00Z" stale="2025-12-08T14:01:00Z" how="m-g">
903                <point lat="0" lon="0" hae="0" ce="10" le="10"/>
904                <detail/>
905            </event>"#;
906
907        let result = CotEvent::from_xml(xml);
908        assert!(matches!(result, Err(CotError::MissingField("uid"))));
909    }
910
911    #[test]
912    fn test_cot_event_from_xml_missing_point() {
913        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
914            <event uid="TEST" type="a-f-G" time="2025-12-08T14:00:00Z"
915                   start="2025-12-08T14:00:00Z" stale="2025-12-08T14:01:00Z" how="m-g">
916                <detail/>
917            </event>"#;
918
919        let result = CotEvent::from_xml(xml);
920        assert!(matches!(result, Err(CotError::MissingField("point"))));
921    }
922}