use std::{
fmt::{self, Display, Formatter},
result::Result,
str::FromStr,
sync::LazyLock,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, NoneAsEmptyString};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use time::OffsetDateTime;
use crate::{assoc::HasBounds, client::APIError, Callsign, Client, ParseError};
#[serde_as]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Summit {
pub name: String,
pub association_code: String,
pub association_name: String,
pub summit_code: String,
pub region_code: String,
pub region_name: String,
pub alt_m: u16,
pub alt_ft: u16,
pub grid_ref_1: String,
pub longitude: f32,
pub grid_ref_2: String,
pub latitude: f32,
pub locator: String,
#[serde_as(as = "NoneAsEmptyString")]
pub notes: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub valid_from: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub valid_to: OffsetDateTime,
pub activation_count: Option<u32>,
pub my_activations: Option<u32>,
pub my_chases: Option<u32>,
#[serde(with = "time::serde::rfc3339::option")]
pub activation_date: Option<OffsetDateTime>,
pub activation_call: Option<Callsign>,
pub points: u8,
pub valid: bool,
pub restriction_list: Option<Vec<SummitRestriction>>,
pub restriction_mask: bool,
}
pub trait HasSummit {
fn summit_code(&self) -> Result<SummitCode, ParseError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub struct SummitCode {
pub association: String,
pub region: String,
pub summit: String,
}
pub static VALID: LazyLock<Regex> = LazyLock::new(|| {
Regex::new("(?<asc>[A-Za-z0-9]+)/(?<rgn>[A-Za-z?]+)-(?<smt>[0-9?]+)").unwrap()
});
pub static STRICT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new("^(?<asc>[A-Z0-9]{1,3})/(?<rgn>[A-Z]{2})-(?<smt>[0-9]{3})$").unwrap()
});
impl SummitCode {
fn parse(s: &str) -> Result<(String, String, String), ParseError> {
let err = || ParseError("summit".into(), s.into());
let captures = VALID.captures(s).ok_or_else(err)?;
let get = |s: &str| {
captures
.name(s)
.as_ref()
.ok_or_else(err)
.map(regex::Match::as_str)
.map(From::from)
};
Ok((get("asc")?, get("rgn")?, get("smt")?))
}
pub fn short_code(&self) -> String {
format!("{}-{}", self.region, self.summit)
}
pub fn is_strictly_valid(&self) -> bool {
STRICT.find(self.to_string().as_str()).is_some()
}
pub fn is_known(&self) -> bool {
self.has_known_summit() && self.has_known_region()
}
pub fn has_known_summit(&self) -> bool {
!self.summit.contains("?")
}
pub fn has_known_region(&self) -> bool {
!self.region.contains("?")
}
pub async fn maidenhead(&self, sota: &Client) -> Result<String, APIError> {
if self.is_known() {
return Ok(sota.summit(self).await?.locator);
};
let (sw, ne) = match self.has_known_region() {
true => sota
.region(&self.association, &self.region)
.await?
.maidenhead(4),
false => sota.association(&self.association).await?.maidenhead(4),
}?;
let unit = match self.has_known_region() {
true => "Region",
false => "Association",
};
Ok(match sw != ne {
true => format!("{unit} covers {sw}-{ne}"),
false => format!("{unit} is in {sw}"),
})
}
}
impl Display for SummitCode {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}-{}", self.association, self.region, self.summit)
}
}
impl FromStr for SummitCode {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parse(&s.to_uppercase())?.into())
}
}
impl From<(String, String, String)> for SummitCode {
fn from(value: (String, String, String)) -> Self {
Self {
association: value.0,
region: value.1,
summit: match value.2.parse::<u8>() {
Ok(n) => format!("{n:03}"),
Err(_) => value.2,
},
}
}
}
#[allow(missing_docs)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct SummitRestriction {
pub code: u16,
pub r#type: String,
}
impl HasSummit for Summit {
fn summit_code(&self) -> Result<SummitCode, ParseError> {
self.summit_code.parse()
}
}
#[cfg(test)]
mod test {
use super::*;
use httpmock::MockServer;
fn convert<'a>(v: &[&'a str]) -> impl Iterator<Item = SummitCode> + use<'a> {
v.to_owned()
.into_iter()
.map(SummitCode::from_str)
.map(Result::unwrap)
}
const CANONICAL_SUMMITS: &[&str] = &["W6/CC-300", "PY2/SE-011"];
const NON_CANONICAL_SUMMITS: &[&str] = &["W6/CC-99999", "AAAAAAAA/AA-11111"];
const UNKNOWN_SUMMITS: &[&str] = &["W6/CC-?", "PY2/SE-???", "AAAAAAAA/AA-11?11"];
const UNKNOWN_REGIONS: &[&str] = &["W6/?-?", "PY2/??-???", "W6/???-???", "AAAAAAAA/A?-1????"];
const INVALID_CODES: &[&str] = &["?/?-?", "W6/66-666", "W6/NC-XXX", "???", "AAAAAAAAAA"];
#[test]
fn test_summit_codes() {
convert(CANONICAL_SUMMITS).for_each(|sc| {
assert!(sc.is_strictly_valid());
assert!(sc.has_known_region());
assert!(sc.has_known_summit())
});
convert(NON_CANONICAL_SUMMITS).for_each(|sc| {
assert!(!sc.is_strictly_valid());
assert!(sc.has_known_region());
assert!(sc.has_known_summit())
});
convert(UNKNOWN_SUMMITS).for_each(|sc| {
assert!(!sc.is_strictly_valid());
assert!(sc.has_known_region());
assert!(!sc.has_known_summit())
});
convert(UNKNOWN_REGIONS).for_each(|sc| {
assert!(!sc.is_strictly_valid());
assert!(!sc.has_known_region());
assert!(!sc.has_known_summit())
});
assert_eq!(
SummitCode::from_str("w6/cc-1").unwrap(),
SummitCode::from_str("W6/CC-001").unwrap(),
);
assert_eq!(
SummitCode::from_str("W6/CC-00000000000001").unwrap(),
SummitCode::from_str("W6/CC-001").unwrap(),
);
assert!(INVALID_CODES
.to_owned()
.into_iter()
.map(SummitCode::from_str)
.all(|result| result.is_err()));
}
async fn assert_maidenhead(client: &Client, case: &str, expected: &str) {
assert_eq!(
SummitCode::from_str(case)
.unwrap()
.maidenhead(&client)
.await
.unwrap(),
expected
);
}
#[tokio::test]
async fn test_summit_code_maidenhead() {
let server = MockServer::start();
let c = Client::new_with_base(&server.base_url(), "");
let summit = server.mock(|when, then| {
when.path("/summits/W6/CC-063");
then.status(200)
.header("content-type", "application/json")
.body(include_str!("client/test/summit.json"));
});
let region = server.mock(|when, then| {
when.path("/regions/W6/CC");
then.status(200)
.header("content-type", "application/json")
.body(include_str!("client/test/region.json"));
});
let assoc = server.mock(|when, then| {
when.path("/associations/W6");
then.status(200)
.header("content-type", "application/json")
.body(include_str!("client/test/assoc.json"));
});
assert_maidenhead(&c, "W6/CC-063", "CM87qw").await;
summit.assert();
assert_maidenhead(&c, "W6/CC-???", "Region covers CM72-DN11").await;
region.assert();
assert_maidenhead(&c, "W6/??-???", "Association covers CM72-DN22").await;
assoc.assert();
}
}