aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
//! Garmin FlightPlan v1 wire codec: decode UTF-16/UTF-8 `.fpl` bytes into
//! a [`FlightPlan`], and serialize one back out.

use std::collections::HashMap;

use aerocontext_core::GeoPoint;
use chrono::{DateTime, NaiveDateTime, Utc};
use quick_xml::Reader;
use quick_xml::events::Event;

use super::{FlightPlan, PlanWaypoint, WaypointType};

/// Failure parsing a Garmin `.fpl` document.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FplError {
    /// The bytes were not valid XML.
    #[error("flight plan is not valid XML")]
    Xml(#[from] quick_xml::Error),
    /// A text node could not be decoded.
    #[error("flight plan text could not be decoded")]
    Encoding(#[from] quick_xml::encoding::EncodingError),
    /// A waypoint-table entry was missing a required field.
    #[error("waypoint {identifier:?} is missing its {field}")]
    IncompleteWaypoint {
        /// The waypoint identifier, when known.
        identifier: String,
        /// The missing field name.
        field: &'static str,
    },
    /// A `<lat>`/`<lon>` value did not parse as a coordinate.
    #[error("waypoint {identifier:?} has an unparseable {field} {value:?}")]
    BadCoordinate {
        /// The waypoint identifier.
        identifier: String,
        /// `lat` or `lon`.
        field: &'static str,
        /// The offending text.
        value: String,
    },
    /// A route point referenced an identifier absent from the waypoint
    /// table; a corridor must never be built from a hole.
    #[error("route point {identifier:?} is not in the waypoint table")]
    UnknownRoutePoint {
        /// The dangling identifier.
        identifier: String,
    },
    /// The document carried no route points.
    #[error("flight plan has no route")]
    EmptyRoute,
}

/// A waypoint-table entry under construction.
#[derive(Default)]
struct PartialWaypoint {
    identifier: Option<String>,
    kind: Option<String>,
    lat: Option<String>,
    lon: Option<String>,
    country_code: Option<String>,
}

/// Decode `.fpl` bytes (UTF-16 with BOM, as ForeFlight exports, or UTF-8)
/// into a [`FlightPlan`].
pub(super) fn parse(bytes: &[u8]) -> Result<FlightPlan, FplError> {
    let text = decode(bytes);
    let mut reader = Reader::from_str(&text);
    reader.config_mut().trim_text(true);

    let mut table: HashMap<String, PlanWaypoint> = HashMap::new();
    let mut order: Vec<(String, Option<String>)> = Vec::new();

    let mut plan = FlightPlan::default();
    let mut path: Vec<String> = Vec::new();
    let mut current = PartialWaypoint::default();
    let mut text_buf = String::new();

    loop {
        match reader.read_event()? {
            Event::Eof => break,
            Event::Start(start) => {
                let name = local_name(start.name().as_ref());
                if name == "waypoint" && in_path(&path, "waypoint-table") {
                    current = PartialWaypoint::default();
                }
                path.push(name);
                text_buf.clear();
            }
            Event::Text(event) => {
                text_buf.push_str(&event.xml_content()?);
            }
            Event::End(end) => {
                let name = local_name(end.name().as_ref());
                let value = text_buf.trim().to_owned();
                handle_leaf(&path, &value, &mut plan, &mut current, &mut order);
                if name == "waypoint"
                    && in_path(&path[..path.len().saturating_sub(1)], "waypoint-table")
                {
                    finish_waypoint(std::mem::take(&mut current), &mut table)?;
                }
                path.pop();
                text_buf.clear();
            }
            _ => {}
        }
    }

    if order.is_empty() {
        return Err(FplError::EmptyRoute);
    }
    plan.route = order
        .into_iter()
        .map(|(ident, _)| {
            table
                .get(&ident)
                .cloned()
                .ok_or(FplError::UnknownRoutePoint { identifier: ident })
        })
        .collect::<Result<_, _>>()?;
    Ok(plan)
}

/// Apply a finished leaf element's text to the plan or the current
/// waypoint, by its position in the element tree.
fn handle_leaf(
    path: &[String],
    value: &str,
    plan: &mut FlightPlan,
    current: &mut PartialWaypoint,
    order: &mut Vec<(String, Option<String>)>,
) {
    let Some(leaf) = path.last() else {
        return;
    };
    let parent = path.get(path.len().wrapping_sub(2)).map(String::as_str);
    match (parent, leaf.as_str()) {
        (_, "created") => plan.created = parse_garmin_time(value),
        (Some("flight-data"), "etd-zulu") => plan.etd = parse_garmin_time(value),
        (Some("flight-data"), "altitude-ft") => {
            plan.cruise_altitude_ft = value.trim().parse().ok();
        }
        (Some("aircraft"), "aircraft-tailnumber") => plan.aircraft_tail = some_nonempty(value),
        (Some("route"), "route-name") => plan.name = some_nonempty(value),
        (Some("waypoint"), "identifier") => current.identifier = some_nonempty(value),
        (Some("waypoint"), "type") => current.kind = some_nonempty(value),
        (Some("waypoint"), "lat") => current.lat = some_nonempty(value),
        (Some("waypoint"), "lon") => current.lon = some_nonempty(value),
        (Some("waypoint"), "country-code") => current.country_code = some_nonempty(value),
        (Some("route-point"), "waypoint-identifier") => {
            if let Some(ident) = some_nonempty(value) {
                order.push((ident, None));
            }
        }
        _ => {}
    }
}

/// Resolve a finished waypoint-table entry into the table.
fn finish_waypoint(
    partial: PartialWaypoint,
    table: &mut HashMap<String, PlanWaypoint>,
) -> Result<(), FplError> {
    let identifier = partial.identifier.clone().unwrap_or_default();
    let kind_str = partial.kind.ok_or(FplError::IncompleteWaypoint {
        identifier: identifier.clone(),
        field: "type",
    })?;
    if identifier.is_empty() {
        return Err(FplError::IncompleteWaypoint {
            identifier,
            field: "identifier",
        });
    }
    let kind = WaypointType::parse(&kind_str).unwrap_or(WaypointType::User);
    let lat = coord(&identifier, "lat", partial.lat)?;
    let lon = coord(&identifier, "lon", partial.lon)?;
    table.insert(
        identifier.clone(),
        PlanWaypoint {
            identifier,
            kind,
            position: GeoPoint { lat, lon },
            country_code: partial.country_code,
        },
    );
    Ok(())
}

fn coord(identifier: &str, field: &'static str, value: Option<String>) -> Result<f64, FplError> {
    let raw = value.ok_or(FplError::IncompleteWaypoint {
        identifier: identifier.to_owned(),
        field,
    })?;
    raw.trim().parse().map_err(|_| FplError::BadCoordinate {
        identifier: identifier.to_owned(),
        field,
        value: raw,
    })
}

/// Serialize a [`FlightPlan`] to the ForeFlight dialect of Garmin v1.
pub(super) fn to_xml(plan: &FlightPlan) -> String {
    let mut out = String::new();
    out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
    out.push_str("<flight-plan xmlns=\"http://www8.garmin.com/xmlschemas/FlightPlan/v1\">\n");
    if let Some(created) = plan.created {
        out.push_str(&format!(
            "  <created>{}</created>\n",
            format_garmin_time(created)
        ));
    }
    out.push_str("  <aircraft>\n");
    out.push_str(&format!(
        "    <aircraft-tailnumber>{}</aircraft-tailnumber>\n",
        escape(plan.aircraft_tail.as_deref().unwrap_or("NONE"))
    ));
    out.push_str("  </aircraft>\n");
    if plan.etd.is_some() || plan.cruise_altitude_ft.is_some() {
        out.push_str("  <flight-data>\n");
        if let Some(etd) = plan.etd {
            out.push_str(&format!(
                "    <etd-zulu>{}</etd-zulu>\n",
                format_garmin_time(etd)
            ));
        }
        if let Some(alt) = plan.cruise_altitude_ft {
            out.push_str(&format!("    <altitude-ft>{alt}</altitude-ft>\n"));
        }
        out.push_str("  </flight-data>\n");
    }
    // The waypoint table is the deduplicated dictionary the route refers
    // to (first occurrence wins, matching its position).
    out.push_str("  <waypoint-table>\n");
    let mut seen: Vec<&str> = Vec::new();
    for w in &plan.route {
        if seen.contains(&w.identifier.as_str()) {
            continue;
        }
        seen.push(&w.identifier);
        out.push_str("    <waypoint>\n");
        out.push_str(&format!(
            "      <identifier>{}</identifier>\n",
            escape(&w.identifier)
        ));
        out.push_str(&format!("      <type>{}</type>\n", w.kind.as_str()));
        if let Some(country) = &w.country_code {
            out.push_str(&format!(
                "      <country-code>{}</country-code>\n",
                escape(country)
            ));
        }
        out.push_str(&format!("      <lat>{}</lat>\n", w.position.lat));
        out.push_str(&format!("      <lon>{}</lon>\n", w.position.lon));
        out.push_str("    </waypoint>\n");
    }
    out.push_str("  </waypoint-table>\n");
    out.push_str("  <route>\n");
    if let Some(name) = &plan.name {
        out.push_str(&format!("    <route-name>{}</route-name>\n", escape(name)));
    }
    out.push_str("    <flight-plan-index>1</flight-plan-index>\n");
    for w in &plan.route {
        out.push_str("    <route-point>\n");
        out.push_str(&format!(
            "      <waypoint-identifier>{}</waypoint-identifier>\n",
            escape(&w.identifier)
        ));
        out.push_str(&format!(
            "      <waypoint-type>{}</waypoint-type>\n",
            w.kind.as_str()
        ));
        out.push_str("    </route-point>\n");
    }
    out.push_str("  </route>\n");
    out.push_str("</flight-plan>\n");
    out
}

/// Decode `.fpl` bytes to a string: UTF-16 by BOM (ForeFlight's export),
/// else UTF-8 (BOM stripped).
fn decode(bytes: &[u8]) -> String {
    match bytes {
        [0xFF, 0xFE, rest @ ..] => decode_utf16(rest, false),
        [0xFE, 0xFF, rest @ ..] => decode_utf16(rest, true),
        [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
        _ => String::from_utf8_lossy(bytes).into_owned(),
    }
}

fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
    let units = bytes.chunks_exact(2).map(|pair| {
        if big_endian {
            u16::from_be_bytes([pair[0], pair[1]])
        } else {
            u16::from_le_bytes([pair[0], pair[1]])
        }
    });
    char::decode_utf16(units)
        .map(|r| r.unwrap_or('\u{FFFD}'))
        .collect()
}

/// Local element name without any namespace prefix.
fn local_name(raw: &[u8]) -> String {
    let name = String::from_utf8_lossy(raw);
    name.rsplit(':').next().unwrap_or(&name).to_owned()
}

fn in_path(path: &[String], ancestor: &str) -> bool {
    path.iter().any(|p| p == ancestor)
}

fn some_nonempty(value: &str) -> Option<String> {
    let trimmed = value.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_owned())
}

/// Garmin basic time `20260612T04:31:24Z`, falling back to RFC 3339.
fn parse_garmin_time(value: &str) -> Option<DateTime<Utc>> {
    let value = value.trim();
    if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H:%M:%SZ") {
        return Some(naive.and_utc());
    }
    DateTime::parse_from_rfc3339(value)
        .ok()
        .map(|dt| dt.with_timezone(&Utc))
}

fn format_garmin_time(time: DateTime<Utc>) -> String {
    time.format("%Y%m%dT%H:%M:%SZ").to_string()
}

fn escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}