Skip to main content

sidereon_core/astro/
cdm.rs

1//! CCSDS Conjunction Data Message (CDM) KVN and XML format reader and writer.
2//!
3//! CDM (CCSDS 508.0-B-1) describes a predicted close approach between two space
4//! objects: the time of closest approach, miss geometry, collision probability,
5//! and per-object state vectors with RTN position covariance. This module owns
6//! the language-independent grammar for both serializations:
7//!
8//! - KVN (Keyword=Value Notation): line tokenization, the `KEY = VALUE [unit]`
9//!   split, trailing-unit stripping, the `COMMENT HBR =` convention,
10//!   object-block segmentation, the leading-float value parser, the
11//!   state/covariance extraction, and the KVN line layout used on encode.
12//! - XML: a real DOM parse via the `roxmltree` crate (which correctly handles
13//!   the `<?xml?>` declaration, comments, namespaces, entity escaping, and
14//!   encoding), then name-based leaf-element lookup over the document and the
15//!   two `<segment>` subtrees for the same state/covariance extraction. Encoding
16//!   emits the CCSDS document layout through a controlled serializer (see
17//!   [`encode_xml`]).
18//!
19//! Both run identically regardless of the calling language, so they live in the
20//! core.
21//!
22//! Date/time fields cross this boundary as raw strings: resolving `TCA` /
23//! `CREATION_DATE` to a concrete instant (and formatting one back) is the host's
24//! job using its native date/time type, exactly as the TLE epoch is handled. This
25//! module deliberately does not depend on the time-scale machinery, and applies
26//! no calendar validation; it only carries the textual value through.
27
28use crate::astro::xml;
29use crate::validate;
30use roxmltree::{Document, Node};
31use std::fmt;
32
33/// Keys of the six-component state vector, in CCSDS order (position then
34/// velocity). Used to pull the state out of a parsed object block.
35const STATE_KEYS: [&str; 6] = ["X", "Y", "Z", "X_DOT", "Y_DOT", "Z_DOT"];
36/// Keys of the RTN position covariance lower triangle, in CCSDS order.
37const COVARIANCE_KEYS: [&str; 6] = ["CR_R", "CT_R", "CT_T", "CN_R", "CN_T", "CN_N"];
38/// Marker keyword whose value (`OBJECT1` / `OBJECT2`) opens an object block.
39const OBJECT_MARKER: &str = "OBJECT";
40/// Comment prefix: lines beginning with this are dropped before key/value
41/// parsing (the embedded `COMMENT HBR =` value is recovered separately).
42const COMMENT_PREFIX: &str = "COMMENT";
43/// Element name wrapping one object's metadata/data block in the CDM XML schema.
44const SEGMENT_TAG: &str = "segment";
45
46/// A two-object conjunction parsed from a CDM message (KVN or XML). Date/time
47/// fields are the raw textual values; the host resolves them to its own instant
48/// type.
49#[derive(Debug, Clone, PartialEq)]
50pub struct CdmKvn {
51    pub creation_date: Option<String>,
52    pub originator: Option<String>,
53    pub message_id: Option<String>,
54    pub tca: Option<String>,
55    pub miss_distance_m: Option<f64>,
56    pub relative_speed_m_s: Option<f64>,
57    pub collision_probability: Option<f64>,
58    pub collision_probability_method: Option<String>,
59    pub hard_body_radius_m: Option<f64>,
60    pub object1: CdmObject,
61    pub object2: CdmObject,
62}
63
64/// One object's metadata, state vector, and RTN position covariance.
65#[derive(Debug, Clone, PartialEq)]
66pub struct CdmObject {
67    pub object_designator: Option<String>,
68    pub catalog_name: Option<String>,
69    pub object_name: Option<String>,
70    pub international_designator: Option<String>,
71    pub object_type: Option<String>,
72    pub ref_frame: Option<String>,
73    /// Position `(x, y, z)` then velocity `(x_dot, y_dot, z_dot)`.
74    pub state: ((f64, f64, f64), (f64, f64, f64)),
75    /// RTN position covariance lower triangle: CR_R, CT_R, CT_T, CN_R, CN_T, CN_N.
76    pub covariance_rtn: [f64; 6],
77}
78
79/// Failure modes of [`parse_kvn`]. The message strings are the historical public
80/// contract surfaced by the Elixir binding.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CdmError {
83    /// An object block was missing one or more state-vector components.
84    IncompleteStateVector,
85    /// A numeric field was absent, malformed, non-finite, or outside its domain.
86    InvalidField {
87        /// The invalid CDM field.
88        field: &'static str,
89        /// The validation failure category.
90        kind: CdmInputErrorKind,
91    },
92    /// The XML reader was handed text that is not a well-formed XML document.
93    MalformedXml(String),
94}
95
96/// CDM boundary-validation failure category.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CdmInputErrorKind {
99    /// A required field was absent.
100    Missing,
101    /// A floating-point field was NaN or infinite.
102    NonFinite,
103    /// A floating-point field could not be parsed.
104    FloatParse,
105    /// An integer field could not be parsed.
106    IntParse,
107    /// A positive physical field was zero or negative.
108    NotPositive,
109    /// A non-negative physical field was negative.
110    Negative,
111    /// A finite numeric field was outside its accepted range.
112    OutOfRange,
113    /// A civil date field was out of range.
114    InvalidCivilDate,
115    /// A civil time field was out of range.
116    InvalidCivilTime,
117}
118
119impl fmt::Display for CdmInputErrorKind {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        let label = match self {
122            Self::Missing => "missing",
123            Self::NonFinite => "not finite",
124            Self::FloatParse => "invalid float",
125            Self::IntParse => "invalid integer",
126            Self::NotPositive => "not positive",
127            Self::Negative => "negative",
128            Self::OutOfRange => "out of range",
129            Self::InvalidCivilDate => "invalid civil date",
130            Self::InvalidCivilTime => "invalid civil time",
131        };
132        f.write_str(label)
133    }
134}
135
136impl From<&validate::FieldError> for CdmInputErrorKind {
137    fn from(error: &validate::FieldError) -> Self {
138        match error {
139            validate::FieldError::Missing { .. } => Self::Missing,
140            validate::FieldError::NonFinite { .. } => Self::NonFinite,
141            validate::FieldError::FloatParse { .. } => Self::FloatParse,
142            validate::FieldError::IntParse { .. } => Self::IntParse,
143            validate::FieldError::NotPositive { .. } => Self::NotPositive,
144            validate::FieldError::Negative { .. } => Self::Negative,
145            validate::FieldError::OutOfRange { .. } => Self::OutOfRange,
146            validate::FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
147            validate::FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
148        }
149    }
150}
151
152impl fmt::Display for CdmError {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        match self {
155            CdmError::IncompleteStateVector => write!(f, "incomplete state vector"),
156            CdmError::InvalidField { field, kind } => {
157                write!(f, "invalid CDM field {field}: {kind}")
158            }
159            CdmError::MalformedXml(detail) => write!(f, "malformed XML: {detail}"),
160        }
161    }
162}
163
164impl std::error::Error for CdmError {}
165
166/// Parse a CDM in KVN format.
167///
168/// Tokenizes the message, recovers the header/relative-metadata fields, the
169/// `COMMENT HBR =` hard-body radius, and the two object blocks. Date/time fields
170/// are returned verbatim for the host to resolve; presence/format checks on them
171/// (and on `MESSAGE_ID`) are the host's concern. An object block missing any
172/// state component is rejected with [`CdmError::IncompleteStateVector`]. Every
173/// covariance component is required and every accepted numeric state/covariance
174/// value must be finite.
175pub fn parse_kvn(text: &str) -> Result<CdmKvn, CdmError> {
176    let lines = significant_lines(text);
177    let kv = parse_kv_lines(&lines);
178
179    let (object1_kv, object2_kv) = split_object_blocks(&lines);
180    let object1 = parse_object(&object1_kv)?;
181    let object2 = parse_object(&object2_kv)?;
182
183    Ok(CdmKvn {
184        creation_date: kv_get(&kv, "CREATION_DATE"),
185        originator: kv_get(&kv, "ORIGINATOR"),
186        message_id: kv_get(&kv, "MESSAGE_ID"),
187        tca: kv_get(&kv, "TCA"),
188        miss_distance_m: optional_kv_num(&kv, "MISS_DISTANCE")?,
189        relative_speed_m_s: optional_kv_num(&kv, "RELATIVE_SPEED")?,
190        collision_probability: optional_kv_num(&kv, "COLLISION_PROBABILITY")?,
191        collision_probability_method: kv_get(&kv, "COLLISION_PROBABILITY_METHOD"),
192        hard_body_radius_m: parse_hbr(text)?,
193        object1,
194        object2,
195    })
196}
197
198/// Encode a [`CdmKvn`] back to KVN text.
199///
200/// The date/time fields are taken as already-formatted strings (the host owns
201/// the instant-to-string conversion). Numeric values are written with their
202/// shortest round-tripping decimal form, so a re-parse recovers the exact same
203/// bits; the output is therefore round-trip faithful rather than byte-identical
204/// to any one producer.
205pub fn encode_kvn(cdm: &CdmKvn) -> Result<String, CdmError> {
206    validate_cdm(cdm)?;
207    let mut lines: Vec<String> = vec![
208        "CCSDS_CDM_VERS = 1.0".to_string(),
209        format!("CREATION_DATE = {}", opt_str(&cdm.creation_date)),
210        format!("ORIGINATOR = {}", opt_str(&cdm.originator)),
211        format!("MESSAGE_ID = {}", opt_str(&cdm.message_id)),
212        format!("TCA = {}", opt_str(&cdm.tca)),
213        format!("MISS_DISTANCE = {} [m]", opt_num(cdm.miss_distance_m)),
214        format!("RELATIVE_SPEED = {} [m/s]", opt_num(cdm.relative_speed_m_s)),
215        format!(
216            "COLLISION_PROBABILITY = {}",
217            opt_num(cdm.collision_probability)
218        ),
219        format!(
220            "COLLISION_PROBABILITY_METHOD = {}",
221            opt_str(&cdm.collision_probability_method)
222        ),
223    ];
224
225    if let Some(hbr) = cdm.hard_body_radius_m {
226        lines.push(format!("COMMENT HBR = {}", fmt_num(hbr)));
227    }
228
229    lines.extend(encode_object(&cdm.object1, "OBJECT1"));
230    lines.extend(encode_object(&cdm.object2, "OBJECT2"));
231
232    Ok(lines.join("\n"))
233}
234
235/// Parse a CDM in XML format.
236///
237/// Parses the document with `roxmltree` (a real XML DOM reader: the `<?xml?>`
238/// declaration, comments, namespaces, entity escaping, and encoding are handled
239/// by the library, not by string scanning), then reads the header and
240/// relative-metadata leaf elements by name from the document and the per-object
241/// state/covariance from the two `<segment>` subtrees. The CCSDS CDM XML schema
242/// is flat, so a name match uniquely identifies each value (extra sibling
243/// elements such as `CRDOT_R` in a covariance block are ignored). Date/time
244/// fields are returned verbatim for the host to resolve, matching [`parse_kvn`];
245/// text that is not well-formed XML is rejected with [`CdmError::MalformedXml`]
246/// and an object block missing any state component with
247/// [`CdmError::IncompleteStateVector`]. Every covariance component is required
248/// and every accepted numeric state/covariance value must be finite.
249pub fn parse_xml(text: &str) -> Result<CdmKvn, CdmError> {
250    let doc = Document::parse(text).map_err(|e| CdmError::MalformedXml(e.to_string()))?;
251    let root = doc.root();
252
253    let mut segments = root
254        .descendants()
255        .filter(|n| n.is_element() && n.tag_name().name() == SEGMENT_TAG);
256    let object1 = parse_xml_object(segments.next())?;
257    let object2 = parse_xml_object(segments.next())?;
258
259    Ok(CdmKvn {
260        creation_date: node_text(root, "CREATION_DATE"),
261        originator: node_text(root, "ORIGINATOR"),
262        message_id: node_text(root, "MESSAGE_ID"),
263        tca: node_text(root, "TCA"),
264        miss_distance_m: optional_node_num(root, "MISS_DISTANCE")?,
265        relative_speed_m_s: optional_node_num(root, "RELATIVE_SPEED")?,
266        collision_probability: optional_node_num(root, "COLLISION_PROBABILITY")?,
267        collision_probability_method: node_text(root, "COLLISION_PROBABILITY_METHOD"),
268        hard_body_radius_m: optional_node_num(root, "HBR")?,
269        object1,
270        object2,
271    })
272}
273
274/// Encode a [`CdmKvn`] to a CCSDS 508.0-B-1 CDM XML document.
275///
276/// The date/time fields are taken as already-formatted strings (the host owns
277/// the instant-to-string conversion), and numeric values use the shortest
278/// round-tripping decimal form, so the output is round-trip faithful rather than
279/// byte-identical to any one producer. String values are XML-escaped.
280///
281/// This is a controlled straight-line serializer (no parsing, no tag scanning)
282/// rather than a generic streaming-writer dependency: the output is a fixed,
283/// documented CCSDS layout (`cdm > header/body > segment > metadata/data`) whose
284/// element nesting and `units` attributes are the inter-system exchange contract,
285/// and every interpolated value is escaped via [`xml::escape`]. The matching
286/// reader is the vetted `roxmltree` DOM parser in [`parse_xml`].
287pub fn encode_xml(cdm: &CdmKvn) -> Result<String, CdmError> {
288    validate_cdm(cdm)?;
289    let mut lines: Vec<String> = vec![
290        r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string(),
291        r#"<cdm id="CCSDS_CDM_VERS" version="1.0">"#.to_string(),
292        "  <header>".to_string(),
293        "    <CCSDS_CDM_VERS>1.0</CCSDS_CDM_VERS>".to_string(),
294        format!(
295            "    <CREATION_DATE>{}</CREATION_DATE>",
296            opt_str(&cdm.creation_date)
297        ),
298        format!(
299            "    <ORIGINATOR>{}</ORIGINATOR>",
300            xml::escape_opt(&cdm.originator)
301        ),
302        format!(
303            "    <MESSAGE_ID>{}</MESSAGE_ID>",
304            xml::escape_opt(&cdm.message_id)
305        ),
306        "  </header>".to_string(),
307        "  <body>".to_string(),
308        "    <relativeMetadataData>".to_string(),
309        format!("      <TCA>{}</TCA>", opt_str(&cdm.tca)),
310        format!(
311            r#"      <MISS_DISTANCE units="m">{}</MISS_DISTANCE>"#,
312            opt_num(cdm.miss_distance_m)
313        ),
314        format!(
315            r#"      <RELATIVE_SPEED units="m/s">{}</RELATIVE_SPEED>"#,
316            opt_num(cdm.relative_speed_m_s)
317        ),
318        format!(
319            "      <COLLISION_PROBABILITY>{}</COLLISION_PROBABILITY>",
320            opt_num(cdm.collision_probability)
321        ),
322        format!(
323            "      <COLLISION_PROBABILITY_METHOD>{}</COLLISION_PROBABILITY_METHOD>",
324            xml::escape_opt(&cdm.collision_probability_method)
325        ),
326        "    </relativeMetadataData>".to_string(),
327    ];
328
329    lines.extend(encode_xml_segment(&cdm.object1, "OBJECT1"));
330    lines.extend(encode_xml_segment(&cdm.object2, "OBJECT2"));
331    lines.push("  </body>".to_string());
332    lines.push("</cdm>".to_string());
333
334    Ok(lines.join("\n"))
335}
336
337// -- KVN tokenization --
338
339/// Trim every line, then drop blanks and comment lines. Comment values that the
340/// grammar needs (the HBR) are recovered from the raw text separately.
341fn significant_lines(text: &str) -> Vec<String> {
342    text.split('\n')
343        .map(|line| line.trim().to_string())
344        .filter(|line| !line.is_empty() && !line.starts_with(COMMENT_PREFIX))
345        .collect()
346}
347
348/// Build the key/value map from `KEY = VALUE` lines. Later duplicates win, which
349/// only matters for header keys repeated across object blocks (those are read per
350/// block instead). The value has any trailing `[unit]` removed.
351fn parse_kv_lines(lines: &[String]) -> Vec<(String, String)> {
352    lines
353        .iter()
354        .filter_map(|line| {
355            line.split_once('=').map(|(key, value)| {
356                (
357                    key.trim().to_string(),
358                    strip_units(value.trim()).to_string(),
359                )
360            })
361        })
362        .collect()
363}
364
365/// Look up the last value for a key (matching the map-overwrite semantics).
366fn kv_lookup<'a>(kv: &'a [(String, String)], key: &str) -> Option<&'a str> {
367    kv.iter()
368        .rev()
369        .find(|(k, _)| k == key)
370        .map(|(_, v)| v.as_str())
371}
372
373fn kv_get(kv: &[(String, String)], key: &str) -> Option<String> {
374    kv_lookup(kv, key).map(str::to_string)
375}
376
377fn optional_kv_num(kv: &[(String, String)], key: &'static str) -> Result<Option<f64>, CdmError> {
378    match kv_lookup(kv, key) {
379        Some(value) => validate::strict_f64(value, key)
380            .map(Some)
381            .map_err(map_cdm_field_error),
382        None => Ok(None),
383    }
384}
385
386fn required_kv_num(kv: &[(String, String)], key: &'static str) -> Result<f64, CdmError> {
387    let value = kv_lookup(kv, key)
388        .ok_or(validate::FieldError::Missing { field: key })
389        .map_err(map_cdm_field_error)?;
390    validate::strict_f64(value, key).map_err(map_cdm_field_error)
391}
392
393fn required_state_kv_num(kv: &[(String, String)], key: &'static str) -> Result<f64, CdmError> {
394    let value = kv_lookup(kv, key).ok_or(CdmError::IncompleteStateVector)?;
395    validate::strict_f64(value, key).map_err(map_cdm_field_error)
396}
397
398fn map_cdm_field_error(error: validate::FieldError) -> CdmError {
399    CdmError::InvalidField {
400        field: error.field(),
401        kind: CdmInputErrorKind::from(&error),
402    }
403}
404
405/// Remove a trailing bracketed unit (` [m]`, `[m**2/kg]`, ...) and surrounding
406/// whitespace, leaving the bare value.
407fn strip_units(value: &str) -> &str {
408    let trimmed = value.trim_end();
409    if let Some(open) = trimmed.rfind('[') {
410        if trimmed.ends_with(']') {
411            return trimmed[..open].trim_end();
412        }
413    }
414    trimmed
415}
416
417/// Split the line stream into the two object blocks at the `OBJECT =` markers.
418/// CDM always carries exactly two; if fewer are present the blocks are empty and
419/// the state-vector check rejects the message.
420fn split_object_blocks(lines: &[String]) -> (Vec<String>, Vec<String>) {
421    let markers: Vec<usize> = lines
422        .iter()
423        .enumerate()
424        .filter(|(_, line)| {
425            line.split_once('=')
426                .is_some_and(|(key, _)| key.trim() == OBJECT_MARKER)
427        })
428        .map(|(idx, _)| idx)
429        .collect();
430
431    match markers.as_slice() {
432        [i1, i2, ..] => (lines[*i1..*i2].to_vec(), lines[*i2..].to_vec()),
433        _ => (Vec::new(), Vec::new()),
434    }
435}
436
437fn parse_object(lines: &[String]) -> Result<CdmObject, CdmError> {
438    let kv = parse_kv_lines(lines);
439
440    let mut state = [0.0_f64; 6];
441    for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
442        *slot = required_state_kv_num(&kv, key)?;
443    }
444    validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
445
446    let mut covariance_rtn = [0.0_f64; 6];
447    for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
448        *slot = required_kv_num(&kv, key)?;
449    }
450    validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
451    validate_covariance_rtn(&covariance_rtn)?;
452
453    Ok(CdmObject {
454        object_designator: kv_get(&kv, "OBJECT_DESIGNATOR"),
455        catalog_name: kv_get(&kv, "CATALOG_NAME"),
456        object_name: kv_get(&kv, "OBJECT_NAME"),
457        international_designator: kv_get(&kv, "INTERNATIONAL_DESIGNATOR"),
458        object_type: kv_get(&kv, "OBJECT_TYPE"),
459        ref_frame: kv_get(&kv, "REF_FRAME"),
460        state: (
461            (state[0], state[1], state[2]),
462            (state[3], state[4], state[5]),
463        ),
464        covariance_rtn,
465    })
466}
467
468/// Recover the hard-body radius from a `COMMENT HBR = <value>` line (NASA CARA
469/// convention). Scans the raw text case-insensitively, taking the leading
470/// digits-and-dot run as the value.
471fn parse_hbr(text: &str) -> Result<Option<f64>, CdmError> {
472    for line in text.split('\n') {
473        let trimmed = line.trim();
474        let mut rest = match strip_prefix_ci(trimmed, COMMENT_PREFIX) {
475            Some(rest) if starts_with_ascii_ws(rest) => rest.trim_start(),
476            _ => continue,
477        };
478        rest = match strip_prefix_ci(rest, "HBR") {
479            Some(rest) => rest.trim_start(),
480            None => continue,
481        };
482        let rest = match rest.strip_prefix('=') {
483            Some(rest) => rest.trim_start(),
484            None => continue,
485        };
486        let value = strip_units(rest).split_whitespace().next().unwrap_or("");
487        if value.is_empty() {
488            return Ok(None);
489        }
490        return validate::strict_f64(value, "HBR")
491            .map(Some)
492            .map_err(map_cdm_field_error);
493    }
494    Ok(None)
495}
496
497// -- KVN encoding --
498
499fn encode_object(object: &CdmObject, name: &str) -> Vec<String> {
500    let ((x, y, z), (xd, yd, zd)) = object.state;
501    let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
502
503    vec![
504        format!("OBJECT = {name}"),
505        format!("OBJECT_DESIGNATOR = {}", opt_str(&object.object_designator)),
506        format!("OBJECT_NAME = {}", opt_str(&object.object_name)),
507        format!("REF_FRAME = {}", opt_str(&object.ref_frame)),
508        format!("X = {} [km]", fmt_num(x)),
509        format!("Y = {} [km]", fmt_num(y)),
510        format!("Z = {} [km]", fmt_num(z)),
511        format!("X_DOT = {} [km/s]", fmt_num(xd)),
512        format!("Y_DOT = {} [km/s]", fmt_num(yd)),
513        format!("Z_DOT = {} [km/s]", fmt_num(zd)),
514        format!("CR_R = {} [m**2]", fmt_num(cr_r)),
515        format!("CT_R = {} [m**2]", fmt_num(ct_r)),
516        format!("CT_T = {} [m**2]", fmt_num(ct_t)),
517        format!("CN_R = {} [m**2]", fmt_num(cn_r)),
518        format!("CN_T = {} [m**2]", fmt_num(cn_t)),
519        format!("CN_N = {} [m**2]", fmt_num(cn_n)),
520    ]
521}
522
523// -- Value helpers --
524
525/// Shortest round-tripping decimal for a finite value.
526fn fmt_num(value: f64) -> String {
527    format!("{value}")
528}
529
530fn opt_str(value: &Option<String>) -> String {
531    value.clone().unwrap_or_default()
532}
533
534fn opt_num(value: Option<f64>) -> String {
535    value.map(fmt_num).unwrap_or_default()
536}
537
538fn validate_cdm(cdm: &CdmKvn) -> Result<(), CdmError> {
539    validate_optional_num(cdm.miss_distance_m, "MISS_DISTANCE")?;
540    validate_optional_num(cdm.relative_speed_m_s, "RELATIVE_SPEED")?;
541    validate_optional_num(cdm.collision_probability, "COLLISION_PROBABILITY")?;
542    validate_optional_num(cdm.hard_body_radius_m, "HBR")?;
543    validate_object(&cdm.object1)?;
544    validate_object(&cdm.object2)?;
545    Ok(())
546}
547
548fn validate_optional_num(value: Option<f64>, field: &'static str) -> Result<(), CdmError> {
549    match value {
550        Some(value) => validate::finite(value, field)
551            .map(|_| ())
552            .map_err(map_cdm_field_error),
553        None => Ok(()),
554    }
555}
556
557fn validate_object(object: &CdmObject) -> Result<(), CdmError> {
558    let ((x, y, z), (xd, yd, zd)) = object.state;
559    validate::finite_slice(&[x, y, z, xd, yd, zd], "state").map_err(map_cdm_field_error)?;
560    validate::finite_slice(&object.covariance_rtn, "covariance_rtn")
561        .map_err(map_cdm_field_error)?;
562    validate_covariance_rtn(&object.covariance_rtn)
563}
564
565fn validate_covariance_rtn(covariance_rtn: &[f64; 6]) -> Result<(), CdmError> {
566    let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = *covariance_rtn;
567    let covariance = [[cr_r, ct_r, cn_r], [ct_r, ct_t, cn_t], [cn_r, cn_t, cn_n]];
568    validate::validate_covariance_psd(&covariance, "covariance_rtn").map_err(map_cdm_field_error)
569}
570
571/// Case-insensitive ASCII prefix strip.
572fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
573    if text
574        .get(..prefix.len())
575        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
576    {
577        text.get(prefix.len()..)
578    } else {
579        None
580    }
581}
582
583fn starts_with_ascii_ws(text: &str) -> bool {
584    text.chars().next().is_some_and(|c| c.is_ascii_whitespace())
585}
586
587// -- XML parsing --
588
589/// First descendant element of `node` (document order) whose local tag name is
590/// `tag`, returning its trimmed text. `None` if the element is absent, empty, or
591/// self-closing. roxmltree decodes entities and ignores attributes, so the value
592/// is the element's resolved text content.
593fn node_text(node: Node, tag: &str) -> Option<String> {
594    let element = node
595        .descendants()
596        .find(|n| n.is_element() && n.tag_name().name() == tag)?;
597    let text = element.text()?.trim();
598    if text.is_empty() {
599        None
600    } else {
601        Some(text.to_string())
602    }
603}
604
605/// Leaf-element value parsed as a finite float, or `None`.
606fn optional_node_num(node: Node, tag: &'static str) -> Result<Option<f64>, CdmError> {
607    match node_text(node, tag) {
608        Some(value) => validate::strict_f64(&value, tag)
609            .map(Some)
610            .map_err(map_cdm_field_error),
611        None => Ok(None),
612    }
613}
614
615fn required_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
616    let value = node_text(node, tag)
617        .ok_or(validate::FieldError::Missing { field: tag })
618        .map_err(map_cdm_field_error)?;
619    validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
620}
621
622fn required_state_node_num(node: Node, tag: &'static str) -> Result<f64, CdmError> {
623    let value = node_text(node, tag).ok_or(CdmError::IncompleteStateVector)?;
624    validate::strict_f64(&value, tag).map_err(map_cdm_field_error)
625}
626
627/// Build one [`CdmObject`] from its `<segment>` subtree. A missing segment (fewer
628/// than two present) or any missing state component is rejected with
629/// [`CdmError::IncompleteStateVector`]. Every covariance component is required.
630fn parse_xml_object(segment: Option<Node>) -> Result<CdmObject, CdmError> {
631    let segment = segment.ok_or(CdmError::IncompleteStateVector)?;
632
633    let mut state = [0.0_f64; 6];
634    for (slot, key) in state.iter_mut().zip(STATE_KEYS) {
635        *slot = required_state_node_num(segment, key)?;
636    }
637    validate::finite_slice(&state, "state").map_err(map_cdm_field_error)?;
638
639    let mut covariance_rtn = [0.0_f64; 6];
640    for (slot, key) in covariance_rtn.iter_mut().zip(COVARIANCE_KEYS) {
641        *slot = required_node_num(segment, key)?;
642    }
643    validate::finite_slice(&covariance_rtn, "covariance_rtn").map_err(map_cdm_field_error)?;
644    validate_covariance_rtn(&covariance_rtn)?;
645
646    Ok(CdmObject {
647        object_designator: node_text(segment, "OBJECT_DESIGNATOR"),
648        catalog_name: node_text(segment, "CATALOG_NAME"),
649        object_name: node_text(segment, "OBJECT_NAME"),
650        international_designator: node_text(segment, "INTERNATIONAL_DESIGNATOR"),
651        object_type: node_text(segment, "OBJECT_TYPE"),
652        ref_frame: node_text(segment, "REF_FRAME"),
653        state: (
654            (state[0], state[1], state[2]),
655            (state[3], state[4], state[5]),
656        ),
657        covariance_rtn,
658    })
659}
660
661// -- XML encoding --
662
663fn encode_xml_segment(object: &CdmObject, name: &str) -> Vec<String> {
664    let ((x, y, z), (xd, yd, zd)) = object.state;
665    let [cr_r, ct_r, ct_t, cn_r, cn_t, cn_n] = object.covariance_rtn;
666
667    vec![
668        "    <segment>".to_string(),
669        "      <metadata>".to_string(),
670        format!("        <OBJECT>{name}</OBJECT>"),
671        format!(
672            "        <OBJECT_DESIGNATOR>{}</OBJECT_DESIGNATOR>",
673            xml::escape_opt(&object.object_designator)
674        ),
675        format!(
676            "        <OBJECT_NAME>{}</OBJECT_NAME>",
677            xml::escape_opt(&object.object_name)
678        ),
679        format!(
680            "        <REF_FRAME>{}</REF_FRAME>",
681            xml::escape_opt(&object.ref_frame)
682        ),
683        "      </metadata>".to_string(),
684        "      <data>".to_string(),
685        "        <stateVector>".to_string(),
686        format!(r#"          <X units="km">{}</X>"#, fmt_num(x)),
687        format!(r#"          <Y units="km">{}</Y>"#, fmt_num(y)),
688        format!(r#"          <Z units="km">{}</Z>"#, fmt_num(z)),
689        format!(r#"          <X_DOT units="km/s">{}</X_DOT>"#, fmt_num(xd)),
690        format!(r#"          <Y_DOT units="km/s">{}</Y_DOT>"#, fmt_num(yd)),
691        format!(r#"          <Z_DOT units="km/s">{}</Z_DOT>"#, fmt_num(zd)),
692        "        </stateVector>".to_string(),
693        "        <covarianceMatrix>".to_string(),
694        format!(r#"          <CR_R units="m**2">{}</CR_R>"#, fmt_num(cr_r)),
695        format!(r#"          <CT_R units="m**2">{}</CT_R>"#, fmt_num(ct_r)),
696        format!(r#"          <CT_T units="m**2">{}</CT_T>"#, fmt_num(ct_t)),
697        format!(r#"          <CN_R units="m**2">{}</CN_R>"#, fmt_num(cn_r)),
698        format!(r#"          <CN_T units="m**2">{}</CN_T>"#, fmt_num(cn_t)),
699        format!(r#"          <CN_N units="m**2">{}</CN_N>"#, fmt_num(cn_n)),
700        "        </covarianceMatrix>".to_string(),
701        "      </data>".to_string(),
702        "    </segment>".to_string(),
703    ]
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn strip_units_removes_trailing_bracket() {
712        assert_eq!(strip_units("7000.0 [km]"), "7000.0");
713        assert_eq!(strip_units("4.835E-05"), "4.835E-05");
714        assert_eq!(strip_units("0.045663 [m**2/kg]"), "0.045663");
715        assert_eq!(strip_units("97.8 [%]"), "97.8");
716    }
717
718    #[test]
719    fn cdm_covariance_rtn_validation_accepts_psd_lower_triangle() {
720        assert_eq!(
721            validate_covariance_rtn(&[1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
722            Ok(())
723        );
724    }
725
726    #[test]
727    fn cdm_covariance_rtn_validation_rejects_non_psd_lower_triangle() {
728        let expected = Err(CdmError::InvalidField {
729            field: "covariance_rtn",
730            kind: CdmInputErrorKind::NotPositive,
731        });
732
733        assert_eq!(
734            validate_covariance_rtn(&[-1.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
735            expected
736        );
737        assert_eq!(
738            validate_covariance_rtn(&[1.0, 2.0, 1.0, 0.0, 0.0, 1.0]),
739            expected
740        );
741    }
742
743    #[test]
744    fn incomplete_state_vector_is_rejected() {
745        let kvn = "OBJECT = OBJECT1\nX = 7000.0 [km]\nOBJECT = OBJECT2\nX = 1.0 [km]\n";
746        assert_eq!(parse_kvn(kvn), Err(CdmError::IncompleteStateVector));
747    }
748
749    #[test]
750    fn hbr_is_recovered_from_comment_only() {
751        let with_hbr = "COMMENT HBR = 15.5\n";
752        assert_eq!(parse_hbr(with_hbr), Ok(Some(15.5)));
753        assert_eq!(parse_hbr("COMMENT Relative Metadata/Data\n"), Ok(None));
754    }
755
756    #[test]
757    fn kvn_hbr_comment_with_multibyte_leading_token_is_ignored() {
758        let kvn = "\
759CREATION_DATE = 2024-01-01T00:00:00.000
760MESSAGE_ID = HBR_TEST
761COMMENT \u{1f4a5}BR = 15.5
762TCA = 2024-01-01T12:00:00.000
763OBJECT = OBJECT1
764X = 1.0 [km]
765Y = 2.0 [km]
766Z = 3.0 [km]
767X_DOT = 0.1 [km/s]
768Y_DOT = 0.2 [km/s]
769Z_DOT = 0.3 [km/s]
770CR_R = 1.0 [m**2]
771CT_R = 0.0 [m**2]
772CT_T = 1.0 [m**2]
773CN_R = 0.0 [m**2]
774CN_T = 0.0 [m**2]
775CN_N = 1.0 [m**2]
776OBJECT = OBJECT2
777X = 4.0 [km]
778Y = 5.0 [km]
779Z = 6.0 [km]
780X_DOT = 0.4 [km/s]
781Y_DOT = 0.5 [km/s]
782Z_DOT = 0.6 [km/s]
783CR_R = 1.0 [m**2]
784CT_R = 0.0 [m**2]
785CT_T = 1.0 [m**2]
786CN_R = 0.0 [m**2]
787CN_T = 0.0 [m**2]
788CN_N = 1.0 [m**2]
789";
790        let parsed = parse_kvn(kvn).expect("malformed HBR comment must not panic");
791        assert_eq!(parsed.hard_body_radius_m, None);
792    }
793
794    #[test]
795    fn node_text_reads_leaf_value_ignoring_attrs() {
796        let doc = Document::parse(
797            r#"<r><MESSAGE_ID>abc123</MESSAGE_ID><X units="km">2570.097065</X><ORIGINATOR></ORIGINATOR></r>"#,
798        )
799        .unwrap();
800        let root = doc.root();
801        assert_eq!(node_text(root, "MESSAGE_ID").as_deref(), Some("abc123"));
802        // The `units` attribute is ignored; the element text is returned.
803        assert_eq!(node_text(root, "X").as_deref(), Some("2570.097065"));
804        // An empty leaf element yields None.
805        assert_eq!(node_text(root, "ORIGINATOR"), None);
806
807        // A distinct element name sharing a prefix must not match.
808        let only_xdot = Document::parse(r#"<r><X_DOT units="km/s">4.4</X_DOT></r>"#).unwrap();
809        assert_eq!(node_text(only_xdot.root(), "X"), None);
810    }
811
812    #[test]
813    fn xml_parse_decodes_entities_and_ignores_extra_covariance_element() {
814        let xml = r#"<cdm><body>
815<segment><metadata><OBJECT_NAME>SAT A &amp; B</OBJECT_NAME></metadata>
816<data><stateVector>
817<X units="km">1.0</X><Y units="km">2.0</Y><Z units="km">3.0</Z>
818<X_DOT units="km/s">0.1</X_DOT><Y_DOT units="km/s">0.2</Y_DOT><Z_DOT units="km/s">0.3</Z_DOT>
819</stateVector><covarianceMatrix>
820<CR_R units="m**2">41.42</CR_R><CT_R units="m**2">-8.579</CT_R><CT_T units="m**2">2533.0</CT_T>
821<CN_R units="m**2">-23.13</CN_R><CN_T units="m**2">13.36</CN_T><CN_N units="m**2">70.98</CN_N>
822<CRDOT_R units="m**2/s">2.52e-3</CRDOT_R>
823</covarianceMatrix></data></segment>
824<segment><data><stateVector>
825<X units="km">4.0</X><Y units="km">5.0</Y><Z units="km">6.0</Z>
826<X_DOT units="km/s">0.4</X_DOT><Y_DOT units="km/s">0.5</Y_DOT><Z_DOT units="km/s">0.6</Z_DOT>
827</stateVector><covarianceMatrix>
828<CR_R units="m**2">1.0</CR_R><CT_R units="m**2">0.0</CT_R><CT_T units="m**2">1.0</CT_T>
829<CN_R units="m**2">0.0</CN_R><CN_T units="m**2">0.0</CN_T><CN_N units="m**2">1.0</CN_N>
830</covarianceMatrix></data></segment>
831</body></cdm>"#;
832        let cdm = parse_xml(xml).unwrap();
833        // The DOM reader decodes the `&amp;` entity.
834        assert_eq!(cdm.object1.object_name.as_deref(), Some("SAT A & B"));
835        // The trailing CRDOT_R element is not one of the six RTN keys, so the
836        // covariance is exactly the six lower-triangle components.
837        assert_eq!(
838            cdm.object1.covariance_rtn,
839            [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98]
840        );
841    }
842
843    #[test]
844    fn xml_incomplete_state_vector_is_rejected() {
845        let xml = "<cdm><body>\
846<segment><data><stateVector><X units=\"km\">1.0</X></stateVector></data></segment>\
847<segment><data><stateVector></stateVector></data></segment>\
848</body></cdm>";
849        assert_eq!(parse_xml(xml), Err(CdmError::IncompleteStateVector));
850    }
851
852    #[test]
853    fn xml_malformed_document_is_rejected() {
854        // Two root elements is not well-formed XML; the DOM reader rejects it
855        // rather than silently scanning past the structure.
856        assert!(matches!(
857            parse_xml("<segment></segment><segment></segment>"),
858            Err(CdmError::MalformedXml(_))
859        ));
860    }
861
862    #[test]
863    fn xml_round_trips_through_encode_and_parse() {
864        let object = CdmObject {
865            object_designator: Some("12345".to_string()),
866            catalog_name: None,
867            object_name: Some("SAT A & B".to_string()),
868            international_designator: None,
869            object_type: None,
870            ref_frame: Some("EME2000".to_string()),
871            state: ((1.5, 2.5, 3.5), (0.1, 0.2, 0.3)),
872            covariance_rtn: [41.42, -8.579, 2533.0, -23.13, 13.36, 70.98],
873        };
874        let original = CdmKvn {
875            creation_date: Some("2024-01-01T00:00:00.000".to_string()),
876            originator: Some("TEST".to_string()),
877            message_id: Some("ID-1".to_string()),
878            tca: Some("2024-01-01T12:00:00.000".to_string()),
879            miss_distance_m: Some(715.0),
880            relative_speed_m_s: Some(14762.0),
881            collision_probability: Some(4.835e-5),
882            collision_probability_method: Some("FOSTER-1992".to_string()),
883            hard_body_radius_m: None,
884            object1: object.clone(),
885            object2: object,
886        };
887
888        let encoded = encode_xml(&original).expect("valid CDM XML encode");
889        assert!(encoded.starts_with("<?xml"));
890        // The ampersand in the object name must be escaped on encode.
891        assert!(encoded.contains("SAT A &amp; B"));
892
893        let reparsed = parse_xml(&encoded).unwrap();
894        assert_eq!(reparsed.object1.state, original.object1.state);
895        assert_eq!(
896            reparsed.object2.covariance_rtn,
897            original.object2.covariance_rtn
898        );
899        assert_eq!(reparsed.miss_distance_m, original.miss_distance_m);
900        assert_eq!(
901            reparsed.collision_probability,
902            original.collision_probability
903        );
904        assert_eq!(reparsed.message_id, original.message_id);
905        assert_eq!(reparsed.tca, original.tca);
906    }
907
908    #[test]
909    fn optional_non_finite_kvn_fields_are_rejected() {
910        let kvn = "OBJECT = OBJECT1\n\
911X = 1.0 [km]\nY = 2.0 [km]\nZ = 3.0 [km]\n\
912X_DOT = 0.1 [km/s]\nY_DOT = 0.2 [km/s]\nZ_DOT = 0.3 [km/s]\n\
913CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
914CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
915OBJECT = OBJECT2\n\
916X = 4.0 [km]\nY = 5.0 [km]\nZ = 6.0 [km]\n\
917X_DOT = 0.4 [km/s]\nY_DOT = 0.5 [km/s]\nZ_DOT = 0.6 [km/s]\n\
918CR_R = 1.0 [m**2]\nCT_R = 0.0 [m**2]\nCT_T = 1.0 [m**2]\n\
919CN_R = 0.0 [m**2]\nCN_T = 0.0 [m**2]\nCN_N = 1.0 [m**2]\n\
920MISS_DISTANCE = NaN [m]\n";
921
922        assert_eq!(
923            parse_kvn(kvn),
924            Err(CdmError::InvalidField {
925                field: "MISS_DISTANCE",
926                kind: CdmInputErrorKind::NonFinite,
927            })
928        );
929    }
930
931    #[test]
932    fn optional_non_finite_xml_fields_are_rejected() {
933        let xml = r#"<cdm><body>
934<relativeMetadataData><COLLISION_PROBABILITY>inf</COLLISION_PROBABILITY></relativeMetadataData>
935<segment><data><stateVector>
936<X>1.0</X><Y>2.0</Y><Z>3.0</Z><X_DOT>0.1</X_DOT><Y_DOT>0.2</Y_DOT><Z_DOT>0.3</Z_DOT>
937</stateVector><covarianceMatrix>
938<CR_R>1.0</CR_R><CT_R>0.0</CT_R><CT_T>1.0</CT_T><CN_R>0.0</CN_R><CN_T>0.0</CN_T><CN_N>1.0</CN_N>
939</covarianceMatrix></data></segment>
940<segment><data><stateVector>
941<X>4.0</X><Y>5.0</Y><Z>6.0</Z><X_DOT>0.4</X_DOT><Y_DOT>0.5</Y_DOT><Z_DOT>0.6</Z_DOT>
942</stateVector><covarianceMatrix>
943<CR_R>1.0</CR_R><CT_R>0.0</CT_R><CT_T>1.0</CT_T><CN_R>0.0</CN_R><CN_T>0.0</CN_T><CN_N>1.0</CN_N>
944</covarianceMatrix></data></segment>
945</body></cdm>"#;
946
947        assert_eq!(
948            parse_xml(xml),
949            Err(CdmError::InvalidField {
950                field: "COLLISION_PROBABILITY",
951                kind: CdmInputErrorKind::NonFinite,
952            })
953        );
954    }
955
956    #[test]
957    fn encode_rejects_non_finite_public_numeric_fields() {
958        let object = CdmObject {
959            object_designator: None,
960            catalog_name: None,
961            object_name: None,
962            international_designator: None,
963            object_type: None,
964            ref_frame: None,
965            state: ((1.0, 2.0, 3.0), (0.1, 0.2, 0.3)),
966            covariance_rtn: [1.0, 0.0, 1.0, 0.0, 0.0, 1.0],
967        };
968        let mut cdm = CdmKvn {
969            creation_date: None,
970            originator: None,
971            message_id: None,
972            tca: None,
973            miss_distance_m: Some(f64::NAN),
974            relative_speed_m_s: None,
975            collision_probability: None,
976            collision_probability_method: None,
977            hard_body_radius_m: None,
978            object1: object.clone(),
979            object2: object,
980        };
981
982        assert_eq!(
983            encode_kvn(&cdm),
984            Err(CdmError::InvalidField {
985                field: "MISS_DISTANCE",
986                kind: CdmInputErrorKind::NonFinite,
987            })
988        );
989
990        cdm.miss_distance_m = Some(1.0);
991        cdm.object1.state.0 = (f64::INFINITY, 2.0, 3.0);
992        assert_eq!(
993            encode_xml(&cdm),
994            Err(CdmError::InvalidField {
995                field: "state",
996                kind: CdmInputErrorKind::NonFinite,
997            })
998        );
999    }
1000}