sota 0.9.1

API crate for Summits on the Air
Documentation
#![warn(missing_docs)]
//! sota-rs is a crate for interfacing with the Summits on the Air API.
pub mod alert;
pub mod assoc;
pub mod callsign;
pub mod client;
pub mod spot;
pub mod summit;

use std::{
    fmt::{self, Display},
    hash::Hash,
    str::FromStr,
    sync::LazyLock,
};

use regex::Regex;
use serde::{de::DeserializeOwned, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};

use crate::summit::HasSummit;

pub use crate::{
    alert::Alert,
    callsign::Callsign,
    client::Client,
    spot::Spot,
    summit::{Summit, SummitRestriction},
};

/// A spot or alert.
#[allow(missing_docs)]
pub trait Notice: Hash + Eq + DeserializeOwned + Serialize + HasSummit {
    fn callsign(&self) -> &Callsign;
    fn epoch(&self) -> &str;
}

#[allow(clippy::upper_case_acronyms, missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub enum Mode {
    All,
    AM,
    CW,
    Data,
    DV,
    FM,
    SSB,
    Other,
}

impl FromStr for Mode {
    type Err = fmt::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mode = match s.to_lowercase().as_ref() {
            "am" => Mode::AM,
            "cw" => Mode::CW,
            "data" => Mode::Data,
            "dv" => Mode::DV,
            "fm" => Mode::FM,
            "ssb" => Mode::SSB,
            _ => Mode::Other,
        };
        Ok(mode)
    }
}

impl Display for Mode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:?}", self)
    }
}

#[allow(clippy::upper_case_acronyms, missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub enum Band {
    All,
    VLF,
    M(u8),
    Cm(u8),
    Microwave,
}

static WAVELENGTH: LazyLock<Regex> =
    LazyLock::new(|| Regex::new("(?<length>[0-9]+)(?<unit>c?m)").unwrap());

impl Band {
    #[allow(missing_docs)]
    pub const METER_BANDS: &[u8] = &[160, 80, 60, 40, 30, 20, 17, 15, 12, 10, 6, 4, 2];
    #[allow(missing_docs)]
    pub const CENTIMETER_BANDS: &[u8] = &[70, 23];

    /// Parses a string like `"40m"` to `(40, "m")`.
    ///
    /// Checking whether it represents a valid amateur band is left to the caller.
    fn parse_wavelength(s: &str) -> Option<(u8, &str)> {
        let captures = WAVELENGTH.captures(s)?;
        Some((
            captures.name("length")?.as_str().parse().ok()?,
            captures.name("unit")?.into(),
        ))
    }
}

impl FromStr for Band {
    type Err = ParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let err = ParseError("band".into(), s.into());
        let s = s.to_lowercase();

        if let Some(band) = match s.as_ref() {
            "all" => Some(Band::All),
            "vlf" => Some(Band::VLF),
            "microwave" => Some(Band::Microwave),
            _ => None,
        } {
            return Ok(band);
        }

        let (wavelength, unit) = Self::parse_wavelength(&s).ok_or_else(|| err.clone())?;

        if unit == "m" && Band::METER_BANDS.contains(&wavelength) {
            Ok(Band::M(wavelength))
        } else if unit == "cm" && Band::CENTIMETER_BANDS.contains(&wavelength) {
            Ok(Band::Cm(wavelength))
        } else {
            Err(err)
        }
    }
}

impl Display for Band {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Band::All | Band::VLF | Band::Microwave => write!(f, "{:?}", self),
            Band::M(l) => write!(f, "{}m", l),
            Band::Cm(l) => write!(f, "{}cm", l),
        }
    }
}

/// An error encountered while parsing something.
#[derive(Clone, Debug, thiserror::Error)]
#[error("unrecognized {0}: {1}")]
pub struct ParseError(String, String);

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_bands() {
        let round_trip = |bands: &[u8], unit: &str| {
            for wavelength in bands {
                let string = format!("{wavelength}{unit}");
                let band = Band::from_str(&string).unwrap();
                assert_eq!(band.to_string(), string);
            }
        };
        round_trip(Band::METER_BANDS, "m");
        round_trip(Band::CENTIMETER_BANDS, "cm");

        assert_eq!(
            Band::from_str("microwave").unwrap(),
            Band::from_str("MICROWAVE").unwrap()
        );
        assert_eq!(
            Band::from_str("vlf").unwrap(),
            Band::from_str("VLF").unwrap()
        );
    }

    #[test]
    fn test_nonexistent_bands() {
        for wavelength in 21..=29 {
            assert!(Band::from_str(&format!("{wavelength}m")).is_err())
        }
        assert!(Band::from_str("asdf").is_err())
    }
}