aerocontext-core 0.3.1

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! Cycle-aware navigation-point data.

pub mod airway;

use chrono::{Days, NaiveDate};
use serde::{Deserialize, Serialize};

pub use airway::{Airway, AirwayLocation, AirwayPoint};

use crate::model::GeoPoint;

/// FAA NASR subscriber files advance on a 28-day effective cycle.
/// The 28-day AIRAC grid every FAA aeronautical product follows.
pub const AIRAC_CYCLE_DAYS: u64 = 28;

/// NASR subscriber cadence (the AIRAC grid).
pub const FAA_NASR_CYCLE_DAYS: u64 = AIRAC_CYCLE_DAYS;

/// FAA page that publishes current, preview, and archived NASR subscriber
/// files.
pub const FAA_NASR_SUBSCRIPTION_URL: &str =
    "https://www.faa.gov/air_traffic/flight_info/aeronav/aero_data/NASR_Subscription/";

/// Authority and product family for a navigation-data snapshot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum NavDataAuthority {
    /// FAA 28 Day NASR Subscription.
    FaaNasr,
    /// FAA Coded Instrument Flight Procedures (ARINC 424).
    FaaCifp,
}

impl NavDataAuthority {
    /// Stable lowercase slug used in manifests, store paths, and release
    /// names, e.g. `"faa-nasr"`. Every variant must map to a unique slug.
    pub fn slug(self) -> &'static str {
        match self {
            Self::FaaNasr => "faa-nasr",
            Self::FaaCifp => "faa-cifp",
        }
    }
}

/// Failure constructing or using navigation data.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum NavDataError {
    /// The next effective date cannot be represented.
    #[error("could not compute {cycle_days}-day navigation-data cycle after {effective_on}")]
    CycleEndOutOfRange {
        /// Cycle effective date.
        effective_on: NaiveDate,
        /// Cycle length in days.
        cycle_days: u64,
    },
}

/// Publication-cycle metadata for a navigation-data snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NavDataCycle {
    /// Authority that published the data.
    pub authority: NavDataAuthority,
    /// First date this snapshot is effective.
    pub effective_on: NaiveDate,
    /// First date a successor cycle is effective.
    pub next_effective_on: NaiveDate,
    /// URL or local product identifier used to create the snapshot.
    pub source: String,
}

impl NavDataCycle {
    /// FAA NASR cycle metadata from an effective date.
    pub fn faa_nasr(effective_on: NaiveDate) -> Result<Self, NavDataError> {
        Self::new(
            NavDataAuthority::FaaNasr,
            effective_on,
            FAA_NASR_CYCLE_DAYS,
            FAA_NASR_SUBSCRIPTION_URL,
        )
    }

    /// Cycle metadata from an authority, effective date, cycle length, and
    /// source identifier.
    pub fn new(
        authority: NavDataAuthority,
        effective_on: NaiveDate,
        cycle_days: u64,
        source: impl Into<String>,
    ) -> Result<Self, NavDataError> {
        let next_effective_on = effective_on.checked_add_days(Days::new(cycle_days)).ok_or(
            NavDataError::CycleEndOutOfRange {
                effective_on,
                cycle_days,
            },
        )?;
        Ok(Self {
            authority,
            effective_on,
            next_effective_on,
            source: source.into(),
        })
    }

    /// Whether `date` falls inside this effective cycle.
    pub fn contains(&self, date: NaiveDate) -> bool {
        self.effective_on <= date && date < self.next_effective_on
    }
}

/// Kind of navigation point carried by a snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum NavPointKind {
    /// Airport, heliport, seaplane base, or similar landing facility.
    Airport,
    /// Published waypoint/fix.
    Waypoint,
    /// Ground-based navigation aid.
    Navaid,
    /// A point kind this crate does not model yet.
    Other(String),
}

/// One published navigation point with WGS84 coordinates.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NavPoint {
    /// Published identifier.
    pub ident: String,
    /// Point family.
    pub kind: NavPointKind,
    /// WGS84 coordinates.
    pub position: GeoPoint,
    /// Human-readable published name when present.
    pub name: Option<String>,
    /// ICAO region of the point (e.g. `"K2"`), when the source provides
    /// one — disambiguates idents that recur across regions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub region: Option<String>,
}

impl NavPoint {
    /// A navigation point with no display name or region.
    pub fn new(ident: impl Into<String>, kind: NavPointKind, position: GeoPoint) -> Self {
        Self {
            ident: ident.into(),
            kind,
            position,
            name: None,
            region: None,
        }
    }

    /// Set the published display name.
    #[must_use]
    pub fn with_name(mut self, name: Option<String>) -> Self {
        self.name = name;
        self
    }

    /// Set the ICAO region.
    #[must_use]
    pub fn with_region(mut self, region: Option<String>) -> Self {
        self.region = region;
        self
    }
}

/// A cycle-tagged set of published navigation points and airways.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NavDataSnapshot {
    /// Publication-cycle metadata.
    pub cycle: NavDataCycle,
    /// Airports, waypoints, and navaids in this snapshot.
    pub points: Vec<NavPoint>,
    /// Published airways, when the source carries them.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub airways: Vec<Airway>,
}

impl NavDataSnapshot {
    /// A snapshot for one publication cycle, without airways.
    pub fn new(cycle: NavDataCycle, points: Vec<NavPoint>) -> Self {
        Self {
            cycle,
            points,
            airways: Vec::new(),
        }
    }

    /// Set the airways.
    #[must_use]
    pub fn with_airways(mut self, airways: Vec<Airway>) -> Self {
        self.airways = airways;
        self
    }

    /// Find a navigation point by identifier, case-insensitively.
    pub fn resolve(&self, ident: &str) -> Option<&NavPoint> {
        let wanted = ident.trim();
        self.points
            .iter()
            .find(|point| point.ident.eq_ignore_ascii_case(wanted))
    }

    /// [`Self::resolve`], preferring an exact `(ident, region)` match when
    /// a region hint is given (airway points carry one), falling back to
    /// the first ident match.
    pub fn resolve_preferring_region(
        &self,
        ident: &str,
        region: Option<&str>,
    ) -> Option<&NavPoint> {
        let wanted = ident.trim();
        if let Some(region) = region
            && let Some(exact) = self.points.iter().find(|point| {
                point.ident.eq_ignore_ascii_case(wanted)
                    && point.region.as_deref().is_some_and(|r| r == region)
            })
        {
            return Some(exact);
        }
        self.resolve(wanted)
    }

    /// Airways matching a designator, case-insensitively (the same ident
    /// can exist per [`AirwayLocation`]).
    pub fn airways_named<'a>(&'a self, designator: &'a str) -> impl Iterator<Item = &'a Airway> {
        let wanted = designator.trim();
        self.airways
            .iter()
            .filter(move |airway| airway.ident.eq_ignore_ascii_case(wanted))
    }

    /// Whether `date` falls inside this snapshot's effective cycle.
    pub fn contains(&self, date: NaiveDate) -> bool {
        self.cycle.contains(date)
    }
}

#[cfg(test)]
mod tests;