sidereon-core 0.11.1

The complete Sidereon engine: numerical astrodynamics propagation core plus the GNSS domain layer (SP3, broadcast ephemeris, multi-GNSS positioning, RTK/PPP, ionosphere/troposphere, DOP) behind a default-on gnss feature
Documentation
use crate::{Error, Result};

#[derive(Clone, Debug, PartialEq)]
pub enum Field<T> {
    Parsed(T),
    Empty,
    Raw(String),
}

impl<T> Field<T> {
    pub fn value(&self) -> Option<&T> {
        match self {
            Field::Parsed(value) => Some(value),
            Field::Empty | Field::Raw(_) => None,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum StrAuth {
    None,
    Basic,
    Digest,
    Other(String),
}

#[derive(Clone, Debug, PartialEq)]
pub struct Sourcetable {
    pub records: Vec<SourcetableRecord>,
}

#[derive(Clone, Debug, PartialEq)]
pub enum SourcetableRecord {
    Str(StrRecord),
    Cas(CasRecord),
    Net(NetRecord),
    Other(OtherRecord),
}

#[derive(Clone, Debug, PartialEq)]
pub struct StrRecord {
    pub mountpoint: String,
    pub identifier: String,
    pub format: String,
    pub format_details: String,
    pub carrier: Field<u8>,
    pub nav_system: String,
    pub network: String,
    pub country: String,
    pub lat_deg: Field<f64>,
    pub lon_deg: Field<f64>,
    pub nmea_required: Field<bool>,
    pub network_solution: Field<bool>,
    pub generator: String,
    pub compression: String,
    pub authentication: StrAuth,
    pub fee: Field<bool>,
    pub bitrate: Field<u32>,
    pub misc: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct CasRecord {
    pub host: String,
    pub port: Field<u16>,
    pub identifier: String,
    pub operator: String,
    pub nmea_required: Field<bool>,
    pub country: String,
    pub lat_deg: Field<f64>,
    pub lon_deg: Field<f64>,
    pub fallback_host: String,
    pub fallback_port: Field<u16>,
    pub misc: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct NetRecord {
    pub identifier: String,
    pub operator: String,
    pub authentication: StrAuth,
    pub fee: Field<bool>,
    pub web_net: String,
    pub web_str: String,
    pub web_reg: String,
    pub misc: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OtherRecord {
    pub type_tag: String,
    pub fields: Vec<String>,
}

pub fn parse_sourcetable(text: &str) -> Result<Sourcetable> {
    let mut records = Vec::new();
    for raw_line in text.lines() {
        let line = raw_line.trim_end_matches('\r');
        if line.is_empty() {
            continue;
        }
        let fields: Vec<&str> = line.split(';').collect();
        let tag = fields[0].trim();
        if tag.eq_ignore_ascii_case("ENDSOURCETABLE") {
            break;
        }
        let record = if tag.eq_ignore_ascii_case("STR") {
            SourcetableRecord::Str(parse_str(&fields))
        } else if tag.eq_ignore_ascii_case("CAS") {
            SourcetableRecord::Cas(parse_cas(&fields))
        } else if tag.eq_ignore_ascii_case("NET") {
            SourcetableRecord::Net(parse_net(&fields))
        } else {
            SourcetableRecord::Other(OtherRecord {
                type_tag: fields[0].to_string(),
                fields: fields.iter().skip(1).map(|s| (*s).to_string()).collect(),
            })
        };
        records.push(record);
    }
    Ok(Sourcetable { records })
}

impl Sourcetable {
    pub fn to_text(&self) -> Result<String> {
        let mut out = String::new();
        for record in &self.records {
            out.push_str(&record.to_line()?);
            out.push_str("\r\n");
        }
        out.push_str("ENDSOURCETABLE\r\n");
        Ok(out)
    }

    pub fn streams(&self) -> impl Iterator<Item = &StrRecord> {
        self.records.iter().filter_map(|record| match record {
            SourcetableRecord::Str(record) => Some(record),
            _ => None,
        })
    }
}

impl SourcetableRecord {
    fn to_line(&self) -> Result<String> {
        match self {
            SourcetableRecord::Str(record) => record.to_line(),
            SourcetableRecord::Cas(record) => record.to_line(),
            SourcetableRecord::Net(record) => record.to_line(),
            SourcetableRecord::Other(record) => {
                let mut fields = vec![ordinary_text(&record.type_tag, "other type tag")?];
                for field in &record.fields {
                    fields.push(ordinary_text(field, "other field")?);
                }
                Ok(fields.join(";"))
            }
        }
    }
}

impl StrRecord {
    fn to_line(&self) -> Result<String> {
        Ok([
            "STR".to_string(),
            ordinary_text(&self.mountpoint, "STR mountpoint")?,
            ordinary_text(&self.identifier, "STR identifier")?,
            ordinary_text(&self.format, "STR format")?,
            ordinary_text(&self.format_details, "STR format details")?,
            field_to_string(&self.carrier, "STR carrier")?,
            ordinary_text(&self.nav_system, "STR nav system")?,
            ordinary_text(&self.network, "STR network")?,
            ordinary_text(&self.country, "STR country")?,
            field_to_string(&self.lat_deg, "STR latitude")?,
            field_to_string(&self.lon_deg, "STR longitude")?,
            bool01_to_string(&self.nmea_required, "STR NMEA required")?,
            bool01_to_string(&self.network_solution, "STR network solution")?,
            ordinary_text(&self.generator, "STR generator")?,
            ordinary_text(&self.compression, "STR compression")?,
            auth_to_string(&self.authentication, "STR authentication")?,
            boolyn_to_string(&self.fee, "STR fee")?,
            field_to_string(&self.bitrate, "STR bitrate")?,
            tail_text(&self.misc, "STR misc")?,
        ]
        .join(";"))
    }
}

impl CasRecord {
    fn to_line(&self) -> Result<String> {
        Ok([
            "CAS".to_string(),
            ordinary_text(&self.host, "CAS host")?,
            field_to_string(&self.port, "CAS port")?,
            ordinary_text(&self.identifier, "CAS identifier")?,
            ordinary_text(&self.operator, "CAS operator")?,
            bool01_to_string(&self.nmea_required, "CAS NMEA required")?,
            ordinary_text(&self.country, "CAS country")?,
            field_to_string(&self.lat_deg, "CAS latitude")?,
            field_to_string(&self.lon_deg, "CAS longitude")?,
            ordinary_text(&self.fallback_host, "CAS fallback host")?,
            field_to_string(&self.fallback_port, "CAS fallback port")?,
            tail_text(&self.misc, "CAS misc")?,
        ]
        .join(";"))
    }
}

impl NetRecord {
    fn to_line(&self) -> Result<String> {
        Ok([
            "NET".to_string(),
            ordinary_text(&self.identifier, "NET identifier")?,
            ordinary_text(&self.operator, "NET operator")?,
            auth_to_string(&self.authentication, "NET authentication")?,
            boolyn_to_string(&self.fee, "NET fee")?,
            ordinary_text(&self.web_net, "NET web net")?,
            ordinary_text(&self.web_str, "NET web str")?,
            ordinary_text(&self.web_reg, "NET web reg")?,
            tail_text(&self.misc, "NET misc")?,
        ]
        .join(";"))
    }
}

fn parse_str(fields: &[&str]) -> StrRecord {
    StrRecord {
        mountpoint: get(fields, 1).to_string(),
        identifier: get(fields, 2).to_string(),
        format: get(fields, 3).to_string(),
        format_details: get(fields, 4).to_string(),
        carrier: parse_field(get(fields, 5)),
        nav_system: get(fields, 6).to_string(),
        network: get(fields, 7).to_string(),
        country: get(fields, 8).to_string(),
        lat_deg: parse_finite_f64_field(get(fields, 9)),
        lon_deg: parse_finite_f64_field(get(fields, 10)),
        nmea_required: parse_bool01(get(fields, 11)),
        network_solution: parse_bool01(get(fields, 12)),
        generator: get(fields, 13).to_string(),
        compression: get(fields, 14).to_string(),
        authentication: parse_auth(get(fields, 15)),
        fee: parse_boolyn(get(fields, 16)),
        bitrate: parse_field(get(fields, 17)),
        misc: join_tail(fields, 18),
    }
}

fn parse_cas(fields: &[&str]) -> CasRecord {
    CasRecord {
        host: get(fields, 1).to_string(),
        port: parse_field(get(fields, 2)),
        identifier: get(fields, 3).to_string(),
        operator: get(fields, 4).to_string(),
        nmea_required: parse_bool01(get(fields, 5)),
        country: get(fields, 6).to_string(),
        lat_deg: parse_finite_f64_field(get(fields, 7)),
        lon_deg: parse_finite_f64_field(get(fields, 8)),
        fallback_host: get(fields, 9).to_string(),
        fallback_port: parse_field(get(fields, 10)),
        misc: join_tail(fields, 11),
    }
}

fn parse_net(fields: &[&str]) -> NetRecord {
    NetRecord {
        identifier: get(fields, 1).to_string(),
        operator: get(fields, 2).to_string(),
        authentication: parse_auth(get(fields, 3)),
        fee: parse_boolyn(get(fields, 4)),
        web_net: get(fields, 5).to_string(),
        web_str: get(fields, 6).to_string(),
        web_reg: get(fields, 7).to_string(),
        misc: join_tail(fields, 8),
    }
}

fn get<'a>(fields: &'a [&str], index: usize) -> &'a str {
    fields.get(index).copied().unwrap_or("")
}

fn join_tail(fields: &[&str], index: usize) -> String {
    if index >= fields.len() {
        String::new()
    } else {
        fields[index..].join(";")
    }
}

fn parse_field<T>(value: &str) -> Field<T>
where
    T: core::str::FromStr,
{
    if value.is_empty() {
        Field::Empty
    } else {
        value
            .parse()
            .map(Field::Parsed)
            .unwrap_or_else(|_| Field::Raw(value.to_string()))
    }
}

fn parse_finite_f64_field(value: &str) -> Field<f64> {
    if value.is_empty() {
        Field::Empty
    } else {
        match value.parse::<f64>() {
            Ok(parsed) if parsed.is_finite() => Field::Parsed(parsed),
            _ => Field::Raw(value.to_string()),
        }
    }
}

fn parse_bool01(value: &str) -> Field<bool> {
    match value {
        "" => Field::Empty,
        "0" => Field::Parsed(false),
        "1" => Field::Parsed(true),
        _ => Field::Raw(value.to_string()),
    }
}

fn parse_boolyn(value: &str) -> Field<bool> {
    match value {
        "" => Field::Empty,
        "N" => Field::Parsed(false),
        "Y" => Field::Parsed(true),
        _ => Field::Raw(value.to_string()),
    }
}

fn parse_auth(value: &str) -> StrAuth {
    match value {
        "N" => StrAuth::None,
        "B" => StrAuth::Basic,
        "D" => StrAuth::Digest,
        other => StrAuth::Other(other.to_string()),
    }
}

fn field_to_string<T: ToString>(field: &Field<T>, name: &str) -> Result<String> {
    match field {
        Field::Parsed(value) => ordinary_text(&value.to_string(), name),
        Field::Empty => Ok(String::new()),
        Field::Raw(value) => ordinary_text(value, name),
    }
}

fn bool01_to_string(field: &Field<bool>, name: &str) -> Result<String> {
    match field {
        Field::Parsed(false) => Ok("0".into()),
        Field::Parsed(true) => Ok("1".into()),
        Field::Empty => Ok(String::new()),
        Field::Raw(value) => ordinary_text(value, name),
    }
}

fn boolyn_to_string(field: &Field<bool>, name: &str) -> Result<String> {
    match field {
        Field::Parsed(false) => Ok("N".into()),
        Field::Parsed(true) => Ok("Y".into()),
        Field::Empty => Ok(String::new()),
        Field::Raw(value) => ordinary_text(value, name),
    }
}

fn auth_to_string(auth: &StrAuth, name: &str) -> Result<String> {
    match auth {
        StrAuth::None => Ok("N".into()),
        StrAuth::Basic => Ok("B".into()),
        StrAuth::Digest => Ok("D".into()),
        StrAuth::Other(value) => ordinary_text(value, name),
    }
}

fn ordinary_text(value: &str, name: &str) -> Result<String> {
    checked_text(value, name, false)
}

fn tail_text(value: &str, name: &str) -> Result<String> {
    checked_text(value, name, true)
}

fn checked_text(value: &str, name: &str, allow_semicolon: bool) -> Result<String> {
    if value.contains('\r') || value.contains('\n') {
        return Err(Error::InvalidInput(format!(
            "sourcetable {name} contains a line break"
        )));
    }
    if !allow_semicolon && value.contains(';') {
        return Err(Error::InvalidInput(format!(
            "sourcetable {name} contains a semicolon"
        )));
    }
    Ok(value.to_string())
}