sota 0.9.1

API crate for Summits on the Air
Documentation
//! Statements of activations in progress.

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

use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_with::{serde_as, DeserializeFromStr, NoneAsEmptyString, SerializeDisplay};
use time::OffsetDateTime;

use crate::{
    callsign::Callsign,
    summit::{HasSummit, SummitCode},
    ParseError,
};

use super::{Mode, Notice};

#[allow(clippy::upper_case_acronyms, missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub enum SpotType {
    Normal,
    QRT,
    Test,
}

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

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_ref() {
            "normal" => Ok(SpotType::Normal),
            "qrt" => Ok(SpotType::QRT),
            "test" => Ok(SpotType::Test),
            _ => Err(fmt::Error),
        }
    }
}

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

/// A SOTA spot.
#[serde_as]
#[derive(Debug, Clone, Deserialize, Serialize, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Spot {
    /// Serial number from API.
    pub id: usize,
    /// Time when the spot was posted.
    #[serde(with = "time::serde::rfc3339")]
    pub time_stamp: OffsetDateTime,
    /// Activator's callsign.
    pub activator_callsign: Callsign,
    /// Activator's name.
    pub activator_name: String,
    /// Poster's callsign.
    // A good reason Callsign doesn't enforce a specific format:
    // `RBNHOLE` is a common poster.
    pub callsign: Callsign,
    /// Comments by the poster.
    #[serde_as(as = "NoneAsEmptyString")]
    pub comments: Option<String>,
    /// Frequency in megahertz.
    #[serde(with = "frequency")]
    pub frequency: Option<String>,
    /// The mode in which the activator is operating.
    pub mode: Mode,
    /// Full code of the summit, e.g. "W6/CC-063".
    pub summit_code: String,
    /// Name of the summit, e.g. "Mount Tamalpais.".
    pub summit_name: String,
    /// Summit altitude in feet.
    #[serde(rename = "AltFt")]
    pub alt_ft: usize,
    /// Summit altitude in meters.
    #[serde(rename = "AltM")]
    pub alt_m: usize,
    /// Point value for chasing this summit.
    pub points: usize,
    /// Spot type.
    pub r#type: Option<SpotType>,
    /// A UUID by which clients should cache responses.
    pub epoch: String,
}

impl Notice for Spot {
    #[allow(clippy::misnamed_getters)]
    fn callsign(&self) -> &Callsign {
        &self.activator_callsign
    }

    fn epoch(&self) -> &str {
        &self.epoch
    }
}

impl HasSummit for Spot {
    fn summit_code(&self) -> Result<SummitCode, ParseError> {
        self.summit_code.parse()
    }
}

// Only essential fields need be hashed/compared, to prevent duplicates
// from things like edited comments.
impl Hash for Spot {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.activator_callsign.hash(state);
        self.summit_code.hash(state);
        self.frequency.hash(state);
        self.mode.hash(state);
        self.time_stamp.hash(state);
    }
}

impl PartialEq for Spot {
    fn eq(&self, other: &Self) -> bool {
        self.activator_callsign == other.activator_callsign
            && self.summit_code == other.summit_code
            && self.frequency == other.frequency
            && self.mode == other.mode
            && self.time_stamp == other.time_stamp
    }
}
mod frequency {
    //! Floats don't implement `Eq`, so frequencies must deserialize to `String`.
    //! But API serves floats, so frequencies must serialize to `f64`.
    use super::*;
    use serde::{de, ser, Deserializer, Serializer};

    pub(super) fn serialize<S: Serializer>(
        freq: &Option<String>,
        serializer: S,
    ) -> Result<S::Ok, S::Error> {
        match freq {
            Some(freq) => f64::from_str(freq)
                .map_err(ser::Error::custom)?
                .serialize(serializer),
            None => serializer.serialize_none(),
        }
    }

    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
    where
        D: Deserializer<'de>,
    {
        Ok(match Value::deserialize(deserializer)? {
            Value::Number(num) => Some(num.to_string()),
            Value::Null => None,
            _ => return Err(de::Error::custom("Expected number")),
        })
    }
}