use std::collections::HashSet;
use std::sync::RwLock;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::client::http_client;
use crate::error::{Error, Result};
use crate::geo::{
detect_region, encode_geohash, get_eccc_office_codes, get_meteoalarm_info, point_in_polygon,
reverse_geocode, MeteoAlarmCodenames, NominatimAddress, Region,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlertSeverity {
Minor,
Moderate,
Severe,
Extreme,
Unknown,
}
impl AlertSeverity {
fn from_cap_string(s: &str) -> Self {
match s.to_lowercase().as_str() {
"minor" => Self::Minor,
"moderate" => Self::Moderate,
"severe" | "major" => Self::Severe,
"extreme" => Self::Extreme,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub id: String,
pub event: String,
pub severity: AlertSeverity,
pub headline: String,
pub description: String,
pub expires: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertEntry {
pub alert: Alert,
pub area_desc: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlertReport {
pub alerts: Vec<AlertEntry>,
pub region_filtered: bool,
}
pub async fn fetch_alerts_detailed(latitude: f64, longitude: f64) -> Result<AlertReport> {
match detect_region(latitude, longitude) {
Region::Us => fetch_nws_alerts(latitude, longitude).await,
Region::Europe => fetch_meteoalarm_alerts(latitude, longitude).await,
Region::Canada => fetch_eccc_alerts(latitude, longitude).await,
Region::Australia => fetch_bom_alerts(latitude, longitude).await,
Region::Unknown => Ok(AlertReport {
alerts: vec![],
region_filtered: true,
}),
}
}
pub async fn fetch_alerts(latitude: f64, longitude: f64) -> Result<Vec<Alert>> {
let report = fetch_alerts_detailed(latitude, longitude).await?;
Ok(report.alerts.into_iter().map(|entry| entry.alert).collect())
}
#[derive(Debug, Deserialize)]
struct NwsAlertsResponse {
features: Vec<NwsAlertFeature>,
}
#[derive(Debug, Deserialize)]
struct NwsAlertFeature {
properties: NwsAlertProperties,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NwsAlertProperties {
id: String,
event: String,
severity: Option<String>,
headline: Option<String>,
description: Option<String>,
sent: String,
expires: Option<String>,
#[serde(rename = "areaDesc")]
area_desc: Option<String>,
}
async fn fetch_nws_alerts(latitude: f64, longitude: f64) -> Result<AlertReport> {
let url = format!(
"https://api.weather.gov/alerts/active?point={},{}",
latitude, longitude
);
let response = http_client()?
.get(&url)
.header("Accept", "application/geo+json")
.send()
.await?;
if !response.status().is_success() {
tracing::warn!("NWS API returned status: {}", response.status());
return Ok(AlertReport {
alerts: vec![],
region_filtered: true,
});
}
let data: NwsAlertsResponse = response.json().await?;
let alerts = nws_alerts_from_response(data);
tracing::debug!("Fetched {} alert(s) from NWS", alerts.len());
Ok(AlertReport {
alerts,
region_filtered: true,
})
}
fn nws_alerts_from_response(data: NwsAlertsResponse) -> Vec<AlertEntry> {
data.features
.into_iter()
.filter_map(|feature| {
let props = feature.properties;
let sent = DateTime::parse_from_rfc3339(&props.sent)
.ok()?
.with_timezone(&Utc);
let expires = props
.expires
.as_ref()
.and_then(|e| DateTime::parse_from_rfc3339(e).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| sent + chrono::Duration::hours(24));
if expires < Utc::now() {
return None;
}
Some(AlertEntry {
alert: Alert {
id: props.id,
event: props.event,
severity: props
.severity
.as_deref()
.map(AlertSeverity::from_cap_string)
.unwrap_or(AlertSeverity::Unknown),
headline: props.headline.unwrap_or_default(),
description: props.description.unwrap_or_default(),
expires,
},
area_desc: props.area_desc.unwrap_or_default(),
})
})
.collect()
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmFeed {
#[serde(rename = "entry", default)]
entries: Vec<MeteoAlarmEntry>,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmEntry {
id: String,
title: Option<String>,
#[serde(rename = "identifier")]
cap_identifier: Option<String>,
#[serde(rename = "event")]
cap_event: Option<String>,
#[serde(rename = "severity")]
cap_severity: Option<String>,
#[serde(rename = "sent")]
cap_sent: Option<String>,
#[serde(rename = "expires")]
cap_expires: Option<String>,
#[serde(rename = "geocode")]
cap_geocode: Option<MeteoAlarmGeocode>,
#[serde(rename = "areaDesc")]
cap_area_desc: Option<String>,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmGeocode {
#[serde(rename = "valueName")]
value_name: Option<String>,
value: Option<String>,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmJsonFeed {
#[serde(default)]
warnings: Vec<MeteoAlarmJsonWarning>,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmJsonWarning {
alert: MeteoAlarmJsonAlert,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmJsonAlert {
#[serde(default)]
info: Vec<MeteoAlarmJsonInfo>,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmJsonInfo {
language: Option<String>,
#[serde(default)]
area: Vec<MeteoAlarmJsonArea>,
}
#[derive(Debug, Deserialize)]
struct MeteoAlarmJsonArea {
#[serde(rename = "areaDesc")]
area_desc: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct LocalArea {
local: String,
english: String,
}
async fn resolve_user_emma_id(address: &NominatimAddress, country_code: &str) -> Option<String> {
let codenames = fetch_meteoalarm_codenames().await?;
match_emma_id(address, country_code, &codenames)
}
static CODENAMES_CACHE: RwLock<Option<MeteoAlarmCodenames>> = RwLock::new(None);
fn cached_codenames() -> Option<MeteoAlarmCodenames> {
CODENAMES_CACHE.read().ok()?.clone()
}
fn cache_codenames(codenames: &MeteoAlarmCodenames) {
if let Ok(mut guard) = CODENAMES_CACHE.write() {
*guard = Some(codenames.clone());
}
}
async fn fetch_meteoalarm_codenames() -> Option<MeteoAlarmCodenames> {
if let Some(codenames) = cached_codenames() {
return Some(codenames);
}
let codenames = fetch_meteoalarm_codenames_uncached().await?; cache_codenames(&codenames);
Some(codenames)
}
async fn fetch_meteoalarm_codenames_uncached() -> Option<MeteoAlarmCodenames> {
const CODENAMES_URL: &str =
"https://raw.githubusercontent.com/ktrue/Meteoalarm-warning/master/meteoalarm-codenames.json";
let client = match http_client() {
Ok(client) => client,
Err(e) => {
tracing::warn!(
"No HTTP client for MeteoAlarm codenames ({}); region filter cannot be applied",
e
);
return None;
}
};
let response = match client.get(CODENAMES_URL).send().await {
Ok(response) => response,
Err(e) => {
tracing::warn!(
"MeteoAlarm codenames fetch failed ({}); region filter cannot be applied",
e
);
return None;
}
};
match response.json::<MeteoAlarmCodenames>().await {
Ok(codenames) => Some(codenames),
Err(e) => {
tracing::warn!(
"MeteoAlarm codenames decode failed ({}); region filter cannot be applied",
e
);
None
}
}
}
async fn fetch_meteoalarm_local_areas(slug: &str) -> Option<Vec<LocalArea>> {
let url = format!(
"https://feeds.meteoalarm.org/api/v1/warnings/feeds-{}",
slug
);
let client = match http_client() {
Ok(client) => client,
Err(e) => {
tracing::warn!(
"No HTTP client for MeteoAlarm local area names ({}); national feed stays unfiltered",
e
);
return None;
}
};
let response = match client.get(&url).send().await {
Ok(response) => response,
Err(e) => {
tracing::warn!(
"MeteoAlarm local area names fetch failed ({}); national feed stays unfiltered",
e
);
return None;
}
};
match response.json::<MeteoAlarmJsonFeed>().await {
Ok(feed) => Some(local_areas_from_json(feed)),
Err(e) => {
tracing::warn!(
"MeteoAlarm local area names decode failed ({}); national feed stays unfiltered",
e
);
None
}
}
}
fn local_areas_from_json(feed: MeteoAlarmJsonFeed) -> Vec<LocalArea> {
let mut pairs = Vec::new();
for warning in feed.warnings {
let (english, local): (Vec<_>, Vec<_>) = warning.alert.info.into_iter().partition(|info| {
info.language
.as_deref()
.map(|l| l.starts_with("en"))
.unwrap_or(false)
});
for local_info in &local {
for english_info in &english {
for (l, e) in local_info.area.iter().zip(english_info.area.iter()) {
if let (Some(l), Some(e)) = (l.area_desc.as_deref(), e.area_desc.as_deref()) {
if !l.is_empty() && !e.is_empty() {
pairs.push(LocalArea {
local: l.to_string(),
english: e.to_string(),
});
}
}
}
}
}
}
pairs.sort();
pairs.dedup();
pairs
}
fn emma_search_terms(address: &NominatimAddress) -> Vec<String> {
let mut terms: Vec<String> = Vec::new();
if let Some(city) = &address.city {
terms.push(city.clone());
terms.push(format!("Stadt {}", city));
}
if let Some(town) = &address.town {
terms.push(town.clone());
}
if let Some(village) = &address.village {
terms.push(village.clone());
}
if let Some(municipality) = &address.municipality {
terms.push(municipality.clone());
}
if let Some(county) = &address.county {
terms.push(county.clone());
terms.push(format!("Kreis {}", county));
}
if let Some(state) = &address.state {
terms.push(state.clone());
}
terms
}
fn match_emma_id(
address: &NominatimAddress,
country_code: &str,
codenames: &MeteoAlarmCodenames,
) -> Option<String> {
let country_prefix = country_code.to_uppercase();
let search_terms = emma_search_terms(address);
for search_term in &search_terms {
let search_lower = search_term.to_lowercase();
let best = codenames
.codes
.iter()
.filter(|(emma_id, _)| emma_id.starts_with(&country_prefix))
.filter_map(|(emma_id, name)| {
rank_emma_match(&search_lower, &name.to_lowercase()).map(|rank| (rank, emma_id))
})
.max_by(|(a_rank, a_id), (b_rank, b_id)| {
a_rank.cmp(b_rank).then_with(|| b_id.cmp(a_id))
});
if let Some((_, emma_id)) = best {
tracing::debug!("Resolved EMMA_ID: {}", emma_id);
return Some(emma_id.clone());
}
}
tracing::warn!(
"No EMMA_ID matched {:?} in {}; will try the feed's own area names",
search_terms,
country_prefix
);
None
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
enum EmmaMatch {
CodenameContainsTerm(std::cmp::Reverse<usize>),
TermContainsCodename(usize),
Exact,
}
fn rank_emma_match(search_lower: &str, name_lower: &str) -> Option<EmmaMatch> {
if search_lower.is_empty() || name_lower.is_empty() {
return None;
}
if name_lower == search_lower {
Some(EmmaMatch::Exact)
} else if search_lower.contains(name_lower) {
Some(EmmaMatch::TermContainsCodename(name_lower.len()))
} else if name_lower.contains(search_lower) {
Some(EmmaMatch::CodenameContainsTerm(std::cmp::Reverse(
name_lower.len(),
)))
} else {
None
}
}
const AREA_AFFIXES: &[&str] = &[
"grad",
"stadt",
"kreis",
"landkreis",
"region",
"county",
"district",
"city",
"municipality",
"powiat",
"gmina",
"okres",
"kraj",
"oblast",
"περιφερεια",
"περιφερειακη",
"ενοτητα",
"δημος",
"νομος",
"област",
"община",
"град",
];
fn area_tokens(name: &str) -> Vec<String> {
use unicode_normalization::UnicodeNormalization;
let folded: String = name
.nfd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.collect::<String>()
.to_lowercase();
folded
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty() && !AREA_AFFIXES.contains(t))
.map(str::to_string)
.collect()
}
fn is_latin(c: char) -> bool {
c.is_ascii_alphabetic()
|| ('\u{00C0}'..='\u{024F}').contains(&c)
|| ('\u{1E00}'..='\u{1EFF}').contains(&c)
}
fn has_non_latin(names: &[String]) -> bool {
names
.iter()
.flat_map(|t| t.chars())
.any(|c| c.is_alphabetic() && !is_latin(c))
}
fn tokens_equal(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let (shorter, longer) = if a.chars().count() <= b.chars().count() {
(a, b)
} else {
(b, a)
};
let non_latin = |s: &str| s.chars().any(|c| c.is_alphabetic() && !is_latin(c));
non_latin(shorter)
&& non_latin(longer)
&& shorter.chars().count() >= 5
&& longer.starts_with(shorter)
&& longer.chars().count() - shorter.chars().count() <= 2
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum AreaMatch {
Tokens,
Exact,
}
fn rank_area_match(term_tokens: &[String], area_tokens: &[String]) -> Option<AreaMatch> {
if term_tokens.is_empty() || area_tokens.is_empty() {
return None;
}
if term_tokens.len() == area_tokens.len()
&& term_tokens
.iter()
.zip(area_tokens)
.all(|(t, a)| tokens_equal(t, a))
{
return Some(AreaMatch::Exact);
}
let (shorter, longer) = if term_tokens.len() <= area_tokens.len() {
(term_tokens, area_tokens)
} else {
(area_tokens, term_tokens)
};
let anchored = shorter.iter().any(|t| t.chars().count() >= 3);
if anchored
&& shorter
.iter()
.all(|t| longer.iter().any(|l| tokens_equal(t, l)))
{
Some(AreaMatch::Tokens)
} else {
None
}
}
fn match_area(search_terms: &[String], area_names: &[String]) -> Option<String> {
for term in search_terms {
let term_tokens = area_tokens(term);
let mut ranked: Vec<(AreaMatch, &str)> = area_names
.iter()
.filter_map(|area| {
rank_area_match(&term_tokens, &area_tokens(area)).map(|rank| (rank, area.as_str()))
})
.collect();
let Some(best) = ranked.iter().map(|(rank, _)| rank.clone()).max() else {
continue;
};
ranked.retain(|(rank, _)| *rank == best);
let mut distinct: Vec<&str> = ranked.iter().map(|(_, area)| *area).collect();
distinct.sort_unstable();
distinct.dedup();
match distinct.as_slice() {
[area] => {
tracing::debug!("Matched area {:?} by place name {:?}", area, term);
return Some(area.to_string());
}
many => tracing::debug!(
"Place name {:?} is ambiguous across {:?}; trying the next",
term,
many
),
}
}
None
}
async fn fetch_meteoalarm_alerts(latitude: f64, longitude: f64) -> Result<AlertReport> {
let address = match reverse_geocode(latitude, longitude).await {
Ok(address) => address,
Err(e) => {
tracing::warn!(
"Reverse geocoding failed ({}); cannot determine country for MeteoAlarm",
e
);
return Err(e);
}
};
let iso_code = match address.country_code.as_deref() {
Some(iso_code) => iso_code,
None => {
tracing::warn!("Reverse geocode returned no country code; cannot select a feed");
return Err(Error::LocationDetection);
}
};
let country = address.country.as_deref().unwrap_or(iso_code);
let (slug, country_code) = match get_meteoalarm_info(iso_code) {
Some(info) => info,
None => {
tracing::debug!("{} ({}) is not covered by MeteoAlarm", country, iso_code);
return Ok(AlertReport {
alerts: vec![],
region_filtered: true,
});
}
};
let user_emma_id = resolve_user_emma_id(&address, country_code).await;
let url = format!(
"https://feeds.meteoalarm.org/feeds/meteoalarm-legacy-atom-{}",
slug
);
let response = http_client()?.get(&url).send().await?;
if !response.status().is_success() {
tracing::warn!("MeteoAlarm returned status: {}", response.status());
return Ok(AlertReport {
alerts: vec![],
region_filtered: user_emma_id.is_some(),
});
}
let xml_text = response.text().await?;
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml_text)?;
let search_terms = emma_search_terms(&address);
let report = meteoalarm_alerts_from_feed(feed, &user_emma_id, &search_terms, country);
if report.region_filtered || !has_non_latin(&search_terms) {
return Ok(report);
}
let Some(local_areas) = fetch_meteoalarm_local_areas(slug).await else {
return Ok(report);
};
Ok(apply_local_area_match(
report,
&search_terms,
&local_areas,
country,
))
}
fn entry_emma_id(entry: &MeteoAlarmEntry) -> Option<&str> {
entry
.cap_geocode
.as_ref()
.filter(|gc| gc.value_name.as_deref() == Some("EMMA_ID"))
.and_then(|gc| gc.value.as_deref())
}
fn meteoalarm_alerts_from_feed(
feed: MeteoAlarmFeed,
user_emma_id: &Option<String>,
search_terms: &[String],
country: &str,
) -> AlertReport {
if feed.entries.is_empty() {
tracing::debug!("Fetched 0 alert(s) from MeteoAlarm ({})", country);
return AlertReport {
alerts: vec![],
region_filtered: true,
};
}
let feed_has_emma_ids = feed
.entries
.iter()
.any(|entry| entry_emma_id(entry).is_some());
if let Some(user_id) = user_emma_id {
if feed_has_emma_ids {
let untagged = feed
.entries
.iter()
.filter(|entry| entry_emma_id(entry).is_none())
.count();
if untagged > 0 {
tracing::debug!(
"Dropped {} untagged MeteoAlarm entr(y/ies) while filtering to {}",
untagged,
user_id
);
}
let filter = Some(user_id.clone());
let alerts: Vec<AlertEntry> = feed
.entries
.into_iter()
.filter_map(|entry| parse_meteoalarm_entry(entry, &filter))
.collect();
tracing::debug!(
"Fetched {} alert(s) from MeteoAlarm ({}), filtered to {}",
alerts.len(),
country,
user_id
);
return AlertReport {
alerts,
region_filtered: true,
};
}
let mut schemes: Vec<&str> = feed
.entries
.iter()
.filter_map(|entry| entry.cap_geocode.as_ref())
.filter_map(|gc| gc.value_name.as_deref())
.collect();
schemes.sort_unstable();
schemes.dedup();
tracing::warn!(
"MeteoAlarm feed ({}) carries no EMMA_ID geocodes (found {:?}); the filter to {} \
cannot apply, matching by area name instead",
country,
schemes,
user_id
);
}
let mut area_names: Vec<String> = feed
.entries
.iter()
.filter_map(|entry| entry.cap_area_desc.clone())
.filter(|name| !name.is_empty())
.collect();
area_names.sort_unstable();
area_names.dedup();
match match_area(search_terms, &area_names) {
Some(area) => {
let alerts: Vec<AlertEntry> = feed
.entries
.into_iter()
.filter(|entry| entry.cap_area_desc.as_deref() == Some(area.as_str()))
.filter_map(|entry| parse_meteoalarm_entry(entry, &None))
.collect();
tracing::debug!(
"Fetched {} alert(s) from MeteoAlarm ({}), filtered to area {:?}",
alerts.len(),
country,
area
);
AlertReport {
alerts,
region_filtered: true,
}
}
None => {
let alerts: Vec<AlertEntry> = feed
.entries
.into_iter()
.filter_map(|entry| parse_meteoalarm_entry(entry, &None))
.collect();
let shown: Vec<&str> = area_names.iter().take(10).map(String::as_str).collect();
tracing::warn!(
"Fetched {} alert(s) from MeteoAlarm ({}), UNFILTERED - no area name matched {:?} \
among {} area(s) ({:?}{}); these are national alerts, not local ones",
alerts.len(),
country,
search_terms,
area_names.len(),
shown,
if area_names.len() > shown.len() {
", ..."
} else {
""
}
);
AlertReport {
alerts,
region_filtered: false,
}
}
}
}
fn apply_local_area_match(
report: AlertReport,
search_terms: &[String],
local_areas: &[LocalArea],
country: &str,
) -> AlertReport {
let local_names: Vec<String> = local_areas.iter().map(|a| a.local.clone()).collect();
if !has_non_latin(&local_names) {
tracing::warn!(
"MeteoAlarm ({}) JSON feed carries no local-language area names ({} English only); \
national feed stays unfiltered",
country,
local_names.len()
);
return report;
}
let Some(local) = match_area(search_terms, &local_names) else {
tracing::warn!(
"No local-language area name matched {:?} among {} for MeteoAlarm ({}); \
national feed stays unfiltered",
search_terms,
local_names.len(),
country
);
return report;
};
let english: Vec<&str> = local_areas
.iter()
.filter(|a| a.local == local)
.map(|a| a.english.as_str())
.collect();
let alerts: Vec<AlertEntry> = report
.alerts
.into_iter()
.filter(|entry| english.contains(&entry.area_desc.as_str()))
.collect();
tracing::debug!(
"Fetched {} alert(s) from MeteoAlarm ({}), filtered to area {:?} via its local name {:?}",
alerts.len(),
country,
english,
local
);
AlertReport {
alerts,
region_filtered: true,
}
}
fn parse_meteoalarm_entry(
entry: MeteoAlarmEntry,
user_emma_id: &Option<String>,
) -> Option<AlertEntry> {
let now = Utc::now();
if let Some(user_id) = user_emma_id {
match entry_emma_id(&entry) {
Some(entry_id) if entry_id != user_id => return None,
None => return None,
_ => {}
}
}
let sent = entry
.cap_sent
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(now);
let expires = entry
.cap_expires
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| sent + chrono::Duration::hours(24));
if expires < now {
return None;
}
let event = entry
.cap_event
.unwrap_or_else(|| "Weather Alert".to_string());
let headline = entry.title.unwrap_or_else(|| event.clone());
let severity = entry
.cap_severity
.as_deref()
.map(AlertSeverity::from_cap_string)
.unwrap_or(AlertSeverity::Unknown);
Some(AlertEntry {
alert: Alert {
id: entry.cap_identifier.unwrap_or(entry.id),
event,
severity,
headline,
description: String::new(),
expires,
},
area_desc: entry.cap_area_desc.unwrap_or_default(),
})
}
#[derive(Debug, Deserialize)]
struct EcccCapAlert {
identifier: String,
status: String,
#[serde(rename = "msgType")]
msg_type: String,
sent: String,
#[serde(rename = "info", default)]
info_blocks: Vec<EcccCapInfo>,
}
#[derive(Debug, Deserialize)]
struct EcccCapInfo {
language: Option<String>,
event: Option<String>,
severity: Option<String>,
expires: Option<String>,
headline: Option<String>,
description: Option<String>,
#[serde(rename = "area", default)]
areas: Vec<EcccCapArea>,
}
#[derive(Debug, Deserialize)]
struct EcccCapArea {
#[serde(rename = "areaDesc")]
area_desc: Option<String>,
polygon: Option<String>,
}
async fn fetch_eccc_alerts(latitude: f64, longitude: f64) -> Result<AlertReport> {
let offices = get_eccc_office_codes(latitude, longitude);
let today = chrono::Utc::now().format("%Y%m%d").to_string();
let client = http_client()?;
let mut all_alerts: Vec<AlertEntry> = Vec::new();
let mut seen_ids: HashSet<String> = HashSet::new();
for office in offices {
let dir_url = format!(
"https://dd.weather.gc.ca/today/alerts/cap/{}/{}/",
today, office
);
let dir_response = match client.get(&dir_url).send().await {
Ok(resp) if resp.status().is_success() => resp,
_ => continue,
};
let dir_html = match dir_response.text().await {
Ok(text) => text,
Err(_) => continue,
};
let hour_dirs: Vec<String> = dir_html
.lines()
.filter_map(|line| {
if line.contains("href=\"") && line.contains("/\"") {
let start = line.find("href=\"")? + 6;
let end = line[start..].find('"')? + start;
let href = &line[start..end];
if href.len() == 3 && href.ends_with('/') {
let hour = &href[..2];
if hour.chars().all(|c| c.is_ascii_digit()) {
return Some(hour.to_string());
}
}
}
None
})
.collect();
for hour in hour_dirs {
let hour_url = format!("{}{}/", dir_url, hour);
let hour_response = match client.get(&hour_url).send().await {
Ok(resp) if resp.status().is_success() => resp,
_ => continue,
};
let hour_html = match hour_response.text().await {
Ok(text) => text,
Err(_) => continue,
};
let cap_files: Vec<String> = hour_html
.lines()
.filter_map(|line| {
if line.contains(".cap\"") {
let start = line.find("href=\"")? + 6;
let end = line[start..].find('"')? + start;
let href = &line[start..end];
if href.ends_with(".cap") {
return Some(href.to_string());
}
}
None
})
.collect();
for cap_file in cap_files {
let cap_url = format!("{}{}", hour_url, cap_file);
let cap_response = match client.get(&cap_url).send().await {
Ok(resp) if resp.status().is_success() => resp,
_ => continue,
};
let cap_xml = match cap_response.text().await {
Ok(text) => text,
Err(_) => continue,
};
if let Some(alert) = parse_eccc_cap(&cap_xml, latitude, longitude, &mut seen_ids) {
all_alerts.push(alert);
}
}
}
}
tracing::debug!("Fetched {} alert(s) from ECCC", all_alerts.len());
Ok(AlertReport {
alerts: all_alerts,
region_filtered: true,
})
}
fn parse_eccc_cap(
xml: &str,
lat: f64,
lon: f64,
seen_ids: &mut HashSet<String>,
) -> Option<AlertEntry> {
let cap: EcccCapAlert = quick_xml::de::from_str(xml).ok()?;
if cap.status != "Actual" {
return None;
}
if cap.msg_type == "Cancel" {
return None;
}
let info = cap
.info_blocks
.iter()
.find(|i| {
i.language
.as_ref()
.map(|l| l.starts_with("en"))
.unwrap_or(false)
})
.or_else(|| cap.info_blocks.first())?;
let area_desc = info.areas.iter().find_map(|area| {
area.polygon
.as_ref()
.filter(|poly| point_in_polygon(lat, lon, poly))
.map(|_| area.area_desc.clone().unwrap_or_default())
});
let area_desc = area_desc?;
let event = info
.event
.clone()
.unwrap_or_else(|| "Weather Alert".to_string());
let dedup_key = format!("{}|{}", event, area_desc);
if seen_ids.contains(&dedup_key) {
return None;
}
seen_ids.insert(dedup_key);
let now = Utc::now();
let sent = cap
.sent
.parse::<DateTime<chrono::FixedOffset>>()
.ok()
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(now);
let expires = info
.expires
.as_ref()
.and_then(|s| s.parse::<DateTime<chrono::FixedOffset>>().ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|| sent + chrono::Duration::hours(24));
if expires < now {
return None;
}
let headline = info.headline.clone().unwrap_or_else(|| event.clone());
Some(AlertEntry {
alert: Alert {
id: cap.identifier,
event,
severity: info
.severity
.as_deref()
.map(AlertSeverity::from_cap_string)
.unwrap_or(AlertSeverity::Unknown),
headline,
description: info.description.clone().unwrap_or_default(),
expires,
},
area_desc,
})
}
#[derive(Debug, Deserialize)]
struct BomWarningsResponse {
data: Vec<BomWarning>,
}
#[derive(Debug, Deserialize)]
struct BomWarning {
id: String,
#[serde(rename = "type")]
warning_type: Option<String>,
short_title: Option<String>,
warning_group_type: Option<String>,
phase: Option<String>,
expiry_time: Option<String>,
}
async fn fetch_bom_alerts(latitude: f64, longitude: f64) -> Result<AlertReport> {
let geohash = encode_geohash(latitude, longitude, 6);
let url = format!(
"https://api.weather.bom.gov.au/v1/locations/{}/warnings",
geohash
);
let response = http_client()?.get(&url).send().await?;
if !response.status().is_success() {
return Ok(AlertReport {
alerts: vec![],
region_filtered: true,
});
}
let response_body: BomWarningsResponse = response.json().await?;
let alerts = bom_alerts_from_response(response_body.data);
Ok(AlertReport {
alerts,
region_filtered: true,
})
}
fn bom_alerts_from_response(data: Vec<BomWarning>) -> Vec<AlertEntry> {
let now = Utc::now();
data.into_iter()
.filter(|w| w.phase.as_deref() != Some("cancelled"))
.filter_map(|w| {
let severity = match w.warning_group_type.as_deref() {
Some("minor") => AlertSeverity::Minor,
Some("moderate") => AlertSeverity::Moderate,
Some("major") | Some("severe") => AlertSeverity::Severe,
Some("extreme") => AlertSeverity::Extreme,
_ => AlertSeverity::Unknown,
};
let expires = w
.expiry_time
.as_ref()
.and_then(|t| DateTime::parse_from_rfc3339(t).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(now + chrono::Duration::hours(24));
if expires < now {
return None;
}
let headline = w
.short_title
.clone()
.unwrap_or_else(|| "Weather Warning".to_string());
let event = w
.warning_type
.as_ref()
.map(|t| t.replace('_', " "))
.unwrap_or_else(|| headline.clone());
Some(AlertEntry {
alert: Alert {
id: w.id.clone(),
event,
severity,
headline,
description: String::new(),
expires,
},
area_desc: String::new(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nws_decodes_active_alert() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-123",
"event":"Tornado Warning",
"severity":"Severe",
"headline":"Tornado Warning until 8 PM",
"description":"Take cover now.",
"sent":"2026-06-01T12:00:00Z",
"expires":"2099-01-01T00:00:00Z"
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert_eq!(alerts.len(), 1);
let alert = &alerts[0].alert;
assert_eq!(alert.id, "NWS-IDP-PROD-123");
assert_eq!(alert.event, "Tornado Warning");
assert_eq!(alert.severity, AlertSeverity::Severe);
assert_eq!(alert.headline, "Tornado Warning until 8 PM");
}
#[test]
fn nws_drops_expired_alert() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-124",
"event":"Winter Storm Warning",
"severity":"Severe",
"headline":"Winter Storm Warning",
"description":"Snow expected.",
"sent":"2026-06-01T12:00:00Z",
"expires":"2020-01-01T01:00:00Z"
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert!(alerts.is_empty());
}
#[test]
fn nws_null_severity_falls_back_to_unknown() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-125",
"event":"Special Weather Statement",
"severity":null,
"headline":"Special Weather Statement",
"description":"Details.",
"sent":"2026-06-01T12:00:00Z",
"expires":"2099-01-01T00:00:00Z"
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert.severity, AlertSeverity::Unknown);
}
#[test]
fn nws_null_expires_uses_sent_plus_24h() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-126",
"event":"Flood Watch",
"severity":"Moderate",
"headline":"Flood Watch",
"description":"Details.",
"sent":"2099-01-01T00:00:00Z",
"expires":null
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert_eq!(alerts.len(), 1);
}
#[test]
fn nws_null_headline_and_description_default_to_empty() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-127",
"event":"Wind Advisory",
"severity":"Minor",
"headline":null,
"description":null,
"sent":"2026-06-01T12:00:00Z",
"expires":"2099-01-01T00:00:00Z"
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert.headline, "");
assert_eq!(alerts[0].alert.description, "");
}
#[test]
fn from_cap_string_maps_all_classes() {
assert_eq!(
AlertSeverity::from_cap_string("minor"),
AlertSeverity::Minor
);
assert_eq!(
AlertSeverity::from_cap_string("moderate"),
AlertSeverity::Moderate
);
assert_eq!(
AlertSeverity::from_cap_string("severe"),
AlertSeverity::Severe
);
assert_eq!(
AlertSeverity::from_cap_string("major"),
AlertSeverity::Severe
);
assert_eq!(
AlertSeverity::from_cap_string("extreme"),
AlertSeverity::Extreme
);
assert_eq!(
AlertSeverity::from_cap_string("not-a-severity"),
AlertSeverity::Unknown
);
}
#[test]
fn bom_decodes_active_severe_warning() {
let json = r#"{"data":[{
"id":"bom-1",
"type":"severe_thunderstorm",
"short_title":"Severe Thunderstorm Warning",
"warning_group_type":"severe",
"phase":"active",
"expiry_time":"2099-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert_eq!(alerts.len(), 1);
let alert = &alerts[0].alert;
assert_eq!(alert.severity, AlertSeverity::Severe);
assert_eq!(alert.event, "severe thunderstorm");
assert_eq!(alert.headline, "Severe Thunderstorm Warning");
}
#[test]
fn bom_major_group_type_maps_to_severe() {
let json = r#"{"data":[{
"id":"bom-1b",
"type":"severe_thunderstorm",
"short_title":"Severe Thunderstorm Warning",
"warning_group_type":"major",
"phase":"active",
"expiry_time":"2099-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert.severity, AlertSeverity::Severe);
}
#[test]
fn bom_drops_cancelled_phase() {
let json = r#"{"data":[{
"id":"bom-1c",
"type":"severe_thunderstorm",
"short_title":"Severe Thunderstorm Warning",
"warning_group_type":"severe",
"phase":"cancelled",
"expiry_time":"2099-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert!(alerts.is_empty());
}
#[test]
fn bom_drops_expired_warning() {
let json = r#"{"data":[{
"id":"bom-1d",
"type":"severe_thunderstorm",
"short_title":"Severe Thunderstorm Warning",
"warning_group_type":"severe",
"phase":"active",
"expiry_time":"2020-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert!(alerts.is_empty());
}
#[test]
fn bom_missing_short_title_uses_default_headline() {
let json = r#"{"data":[{
"id":"bom-2",
"type":"flood",
"short_title":null,
"warning_group_type":"moderate",
"phase":"active",
"expiry_time":"2099-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert.headline, "Weather Warning");
assert_eq!(alerts[0].alert.event, "flood");
}
#[test]
fn bom_missing_warning_type_uses_headline_as_event() {
let json = r#"{"data":[{
"id":"bom-3",
"type":null,
"short_title":"Severe Weather Alert",
"warning_group_type":"severe",
"phase":"active",
"expiry_time":"2099-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert.event, "Severe Weather Alert");
assert_eq!(alerts[0].alert.headline, "Severe Weather Alert");
}
#[test]
fn meteoalarm_entry_decodes_all_fields() {
let xml = r#"<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-1</id>
<title>Wind Warning for Test Region</title>
<cap:identifier>2-717000-DE723</cap:identifier>
<cap:areaDesc>Test Region</cap:areaDesc>
<cap:event>Wind</cap:event>
<cap:severity>Severe</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>DE723</cap:value>
</cap:geocode>
</entry>"#;
let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap();
let entry = parse_meteoalarm_entry(entry, &None).expect("entry should decode to an alert");
assert_eq!(entry.area_desc, "Test Region");
let alert = entry.alert;
assert_eq!(alert.id, "2-717000-DE723");
assert_eq!(alert.event, "Wind");
assert_eq!(alert.severity, AlertSeverity::Severe);
assert_eq!(alert.headline, "Wind Warning for Test Region");
}
#[test]
fn meteoalarm_entry_matches_user_emma_id() {
let xml = r#"<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-2</id>
<title>Wind Warning for Test Region</title>
<cap:identifier>2-717000-DE723</cap:identifier>
<cap:event>Wind</cap:event>
<cap:severity>Severe</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>DE723</cap:value>
</cap:geocode>
</entry>"#;
let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap();
let alert = parse_meteoalarm_entry(entry, &Some("DE723".to_string()));
assert!(alert.is_some());
let alert = alert.unwrap().alert;
assert_eq!(alert.event, "Wind");
assert_eq!(alert.severity, AlertSeverity::Severe);
}
#[test]
fn meteoalarm_entry_filters_wrong_emma_id() {
let xml = r#"<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-3</id>
<title>Wind Warning for Test Region</title>
<cap:identifier>2-717000-DE723</cap:identifier>
<cap:event>Wind</cap:event>
<cap:severity>Severe</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>DE723</cap:value>
</cap:geocode>
</entry>"#;
let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap();
let alert = parse_meteoalarm_entry(entry, &Some("DE999".to_string()));
assert!(alert.is_none());
}
#[test]
fn meteoalarm_entry_drops_expired() {
let xml = r#"<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-4</id>
<title>Wind Warning for Test Region</title>
<cap:identifier>2-717000-DE723</cap:identifier>
<cap:event>Wind</cap:event>
<cap:severity>Severe</cap:severity>
<cap:sent>2020-01-01T00:00:00Z</cap:sent>
<cap:expires>2020-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>DE723</cap:value>
</cap:geocode>
</entry>"#;
let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap();
let alert = parse_meteoalarm_entry(entry, &None);
assert!(alert.is_none());
}
#[test]
fn meteoalarm_feed_decodes_and_maps() {
let xml = r#"<feed>
<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-future</id>
<title>Wind Warning for Test Region (future)</title>
<cap:identifier>2-717000-DE723-future</cap:identifier>
<cap:event>Wind</cap:event>
<cap:severity>Severe</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>DE723</cap:value>
</cap:geocode>
</entry>
<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-past</id>
<title>Wind Warning for Test Region (past)</title>
<cap:identifier>2-717000-DE723-past</cap:identifier>
<cap:event>Wind</cap:event>
<cap:severity>Severe</cap:severity>
<cap:sent>2020-01-01T00:00:00Z</cap:sent>
<cap:expires>2020-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>DE723</cap:value>
</cap:geocode>
</entry>
</feed>"#;
let feed: MeteoAlarmFeed = quick_xml::de::from_str(xml).unwrap();
let alerts = meteoalarm_alerts_from_feed(feed, &None, &[], "test").alerts;
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert.id, "2-717000-DE723-future");
}
fn eccc_fixture(status: &str, msg_type: &str, sent: &str, identifier: &str) -> String {
format!(
r#"<alert>
<identifier>{identifier}</identifier>
<status>{status}</status>
<msgType>{msg_type}</msgType>
<sent>{sent}</sent>
<info>
<language>en-CA</language>
<event>Thunderstorm Warning</event>
<severity>Severe</severity>
<expires>2099-01-01T00:00:00Z</expires>
<headline>Severe Thunderstorm Warning</headline>
<description>Severe thunderstorm expected.</description>
<area>
<areaDesc>Test Region</areaDesc>
<polygon>0,0 10,0 10,10 0,10</polygon>
</area>
</info>
</alert>"#
)
}
#[test]
fn eccc_decodes_active_alert_in_polygon() {
let xml = eccc_fixture(
"Actual",
"Alert",
"2026-06-01T08:00:00-04:00",
"CA-ON-2026-001",
);
let mut seen_ids = HashSet::new();
let alert = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids);
assert!(alert.is_some());
let alert = alert.unwrap().alert;
assert_eq!(alert.event, "Thunderstorm Warning");
assert_eq!(alert.severity, AlertSeverity::Severe);
assert_eq!(alert.id, "CA-ON-2026-001");
}
#[test]
fn eccc_rejects_point_outside_polygon() {
let xml = eccc_fixture(
"Actual",
"Alert",
"2026-06-01T08:00:00-04:00",
"CA-ON-2026-002",
);
let mut seen_ids = HashSet::new();
let alert = parse_eccc_cap(&xml, 50.0, 50.0, &mut seen_ids);
assert!(alert.is_none());
}
#[test]
fn eccc_rejects_non_actual_status() {
let xml = eccc_fixture(
"Test",
"Alert",
"2026-06-01T08:00:00-04:00",
"CA-ON-2026-003",
);
let mut seen_ids = HashSet::new();
let alert = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids);
assert!(alert.is_none());
}
#[test]
fn eccc_rejects_cancel_msgtype() {
let xml = eccc_fixture(
"Actual",
"Cancel",
"2026-06-01T08:00:00-04:00",
"CA-ON-2026-004",
);
let mut seen_ids = HashSet::new();
let alert = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids);
assert!(alert.is_none());
}
#[test]
fn eccc_dedups_same_event_and_area() {
let xml_first = eccc_fixture(
"Actual",
"Alert",
"2026-06-01T08:00:00-04:00",
"CA-ON-2026-005",
);
let xml_second = eccc_fixture(
"Actual",
"Alert",
"2026-06-01T09:00:00-04:00",
"CA-ON-2026-006",
);
let mut seen_ids = HashSet::new();
let first = parse_eccc_cap(&xml_first, 5.0, 5.0, &mut seen_ids);
assert!(first.is_some());
let second = parse_eccc_cap(&xml_second, 5.0, 5.0, &mut seen_ids);
assert!(second.is_none());
}
#[test]
fn eccc_drops_expired_alert() {
let xml = r#"<alert>
<identifier>CA-ON-2026-007</identifier>
<status>Actual</status>
<msgType>Alert</msgType>
<sent>2020-06-01T08:00:00-04:00</sent>
<info>
<language>en-CA</language>
<event>Thunderstorm Warning</event>
<severity>Severe</severity>
<expires>2020-01-01T00:00:00Z</expires>
<headline>Severe Thunderstorm Warning</headline>
<description>Severe thunderstorm expected.</description>
<area>
<areaDesc>Test Region</areaDesc>
<polygon>0,0 10,0 10,10 0,10</polygon>
</area>
</info>
</alert>"#
.to_string();
let mut seen_ids = HashSet::new();
let alert = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids);
assert!(alert.is_none());
}
#[tokio::test]
async fn dispatch_unknown_region_returns_empty() {
let result = fetch_alerts(35.68, 139.65).await;
assert!(matches!(result, Ok(alerts) if alerts.is_empty()));
}
#[test]
fn dispatch_routes_coordinates_to_expected_region() {
assert_eq!(detect_region(40.71, -74.01), Region::Us, "New York");
assert_eq!(detect_region(43.65, -79.38), Region::Canada, "Toronto");
assert_eq!(detect_region(51.51, -0.13), Region::Europe, "London");
assert_eq!(detect_region(-33.87, 151.21), Region::Australia, "Sydney");
assert_eq!(detect_region(35.68, 139.65), Region::Unknown, "Tokyo");
}
fn address(city: Option<&str>, county: Option<&str>, state: Option<&str>) -> NominatimAddress {
NominatimAddress {
country: Some("Polska".to_string()),
country_code: Some("pl".to_string()),
city: city.map(str::to_string),
town: None,
village: None,
municipality: None,
county: county.map(str::to_string),
state: state.map(str::to_string),
}
}
fn codenames(pairs: &[(&str, &str)]) -> MeteoAlarmCodenames {
MeteoAlarmCodenames {
codes: pairs
.iter()
.map(|(id, name)| (id.to_string(), name.to_string()))
.collect(),
}
}
#[test]
fn emma_search_terms_are_most_specific_first() {
let terms = emma_search_terms(&address(
Some("Warsaw"),
Some("Warsaw County"),
Some("Masovian"),
));
assert_eq!(
terms,
vec![
"Warsaw",
"Stadt Warsaw",
"Warsaw County",
"Kreis Warsaw County",
"Masovian",
]
);
}
#[test]
fn match_emma_id_ignores_other_countries() {
let codes = codenames(&[("DE123", "Warsaw")]);
assert_eq!(
match_emma_id(&address(Some("Warsaw"), None, None), "PL", &codes),
None
);
}
#[test]
fn match_emma_id_matches_on_city() {
let codes = codenames(&[("PL1465", "Warsaw"), ("DE123", "Berlin")]);
assert_eq!(
match_emma_id(&address(Some("Warsaw"), None, None), "PL", &codes),
Some("PL1465".to_string())
);
}
#[test]
fn match_emma_id_falls_back_to_state() {
let codes = codenames(&[("PL0100", "Masovian")]);
assert_eq!(
match_emma_id(
&address(Some("Nowhere"), None, Some("Masovian")),
"PL",
&codes
),
Some("PL0100".to_string())
);
}
#[test]
fn match_emma_id_returns_none_when_nothing_matches() {
let codes = codenames(&[("PL1465", "Warsaw")]);
assert_eq!(
match_emma_id(&address(Some("Nowhere"), None, None), "PL", &codes),
None
);
}
#[test]
fn emma_search_terms_includes_town() {
let address = NominatimAddress {
country: Some("Polska".to_string()),
country_code: Some("pl".to_string()),
city: None,
town: Some("Sopot".to_string()),
village: None,
municipality: None,
county: None,
state: None,
};
assert_eq!(emma_search_terms(&address), vec!["Sopot"]);
}
#[test]
fn match_emma_id_matches_real_local_language_pair() {
let codes = codenames(&[("PL1465", "Warszawa")]);
assert_eq!(
match_emma_id(&address(Some("Warszawa"), None, None), "PL", &codes),
Some("PL1465".to_string())
);
}
fn vienna_codenames() -> MeteoAlarmCodenames {
let mut pairs = vec![
("AT010", "Wien"),
("AT304", "Wiener Neustadt (Stadt)"),
("AT323", "Wiener Neustadt (Land)"),
];
let districts: Vec<String> = (901..=923).map(|n| format!("AT{n}")).collect();
for id in &districts {
pairs.push((id.as_str(), "Wien Innere Stadt"));
}
codenames(&pairs)
}
fn vienna_address() -> NominatimAddress {
NominatimAddress {
country: Some("Österreich".to_string()),
country_code: Some("at".to_string()),
city: Some("Wien".to_string()),
town: None,
village: None,
municipality: None,
county: None,
state: Some("Wien".to_string()),
}
}
#[test]
fn match_emma_id_prefers_the_exact_codename_over_longer_ones() {
assert_eq!(
match_emma_id(&vienna_address(), "AT", &vienna_codenames()),
Some("AT010".to_string())
);
}
#[test]
fn match_emma_id_is_stable_across_hashmap_instances() {
let resolved: std::collections::BTreeSet<String> = (0..200)
.filter_map(|_| match_emma_id(&vienna_address(), "AT", &vienna_codenames()))
.collect();
assert_eq!(
resolved,
["AT010".to_string()].into_iter().collect(),
"one input must resolve to exactly one EMMA_ID"
);
}
#[test]
fn match_emma_id_prefers_the_longest_codename_the_term_contains() {
let codes = codenames(&[("FR001", "Rhone"), ("FR002", "Rhone-Alpes")]);
let address = NominatimAddress {
country: Some("France".to_string()),
country_code: Some("fr".to_string()),
city: None,
town: None,
village: None,
municipality: None,
county: None,
state: Some("Auvergne-Rhone-Alpes".to_string()),
};
assert_eq!(
match_emma_id(&address, "FR", &codes),
Some("FR002".to_string())
);
}
#[test]
fn match_emma_id_ties_break_on_the_lower_id() {
let codes = codenames(&[("PL2000", "Warszawa"), ("PL1465", "Warszawa")]);
for _ in 0..50 {
assert_eq!(
match_emma_id(&address(Some("Warszawa"), None, None), "PL", &codes),
Some("PL1465".to_string())
);
}
}
#[test]
fn match_emma_id_ignores_blank_place_names() {
let codes = codenames(&[("PL1465", "Warszawa")]);
assert_eq!(
match_emma_id(&address(Some(""), None, None), "PL", &codes),
None
);
}
#[test]
fn match_emma_id_search_term_order_still_wins_over_rank() {
let codes = codenames(&[("PL1465", "Warszawa Centrum"), ("PL0100", "Masovian")]);
assert_eq!(
match_emma_id(
&address(Some("Warszawa"), None, Some("Masovian")),
"PL",
&codes
),
Some("PL1465".to_string())
);
}
#[test]
fn match_emma_id_matches_when_search_term_contains_codename() {
let codes = codenames(&[("PL1465", "Warsaw")]);
assert_eq!(
match_emma_id(&address(None, Some("Warsaw County"), None), "PL", &codes),
Some("PL1465".to_string())
);
}
#[test]
fn meteoalarm_entry_without_area_desc_is_empty_string() {
let xml = r#"<entry>
<id>https://feeds.meteoalarm.org/feed/example-entry-5</id>
<title>Wind Warning</title>
<cap:event>Wind</cap:event>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
</entry>"#;
let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap();
let entry = parse_meteoalarm_entry(entry, &None).unwrap();
assert_eq!(entry.area_desc, "");
}
fn untagged_feed() -> MeteoAlarmFeed {
let xml = r#"<feed>
<entry>
<id>https://feeds.meteoalarm.org/feed/tagged-elsewhere</id>
<title>Wind Warning for elsewhere</title>
<cap:event>Wind</cap:event>
<cap:severity>Moderate</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<cap:value>PL999</cap:value>
</cap:geocode>
</entry>
<entry>
<id>https://feeds.meteoalarm.org/feed/untagged</id>
<title>Wind Warning with no geocode</title>
<cap:event>Wind</cap:event>
<cap:severity>Moderate</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
</entry>
</feed>"#;
quick_xml::de::from_str(xml).unwrap()
}
#[test]
fn meteoalarm_untagged_entry_dropped_when_filter_active() {
let alerts =
meteoalarm_alerts_from_feed(untagged_feed(), &Some("PL1465".to_string()), &[], "test")
.alerts;
assert!(alerts.is_empty(), "untagged entry leaked past the filter");
}
#[test]
fn meteoalarm_untagged_entry_kept_without_filter() {
let alerts = meteoalarm_alerts_from_feed(untagged_feed(), &None, &[], "test").alerts;
assert_eq!(alerts.len(), 2);
}
#[test]
fn nws_decodes_area_desc() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-128",
"event":"Heat Advisory",
"severity":"Moderate",
"headline":"Heat Advisory until 8 PM",
"description":"Hot.",
"sent":"2026-06-01T12:00:00Z",
"expires":"2099-01-01T00:00:00Z",
"areaDesc":"Coastal Los Angeles County; Los Angeles County Beaches"
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert_eq!(
alerts[0].area_desc,
"Coastal Los Angeles County; Los Angeles County Beaches"
);
}
#[test]
fn nws_missing_area_desc_is_empty_string() {
let json = r#"{"features":[{"properties":{
"id":"NWS-IDP-PROD-129",
"event":"Heat Advisory",
"severity":"Moderate",
"headline":"Heat Advisory",
"description":"Hot.",
"sent":"2026-06-01T12:00:00Z",
"expires":"2099-01-01T00:00:00Z"
}}]}"#;
let data: NwsAlertsResponse = serde_json::from_str(json).unwrap();
let alerts = nws_alerts_from_response(data);
assert_eq!(alerts[0].area_desc, "");
}
#[test]
fn eccc_area_desc_is_the_containing_polygon() {
let xml = eccc_fixture(
"Actual",
"Alert",
"2026-06-01T08:00:00-04:00",
"CA-ON-2026-008",
);
let mut seen_ids = HashSet::new();
let entry = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids).unwrap();
assert_eq!(entry.area_desc, "Test Region");
}
#[test]
fn bom_area_desc_is_empty_string() {
let json = r#"{"data":[{
"id":"bom-4",
"type":"flood",
"short_title":"Flood Warning",
"warning_group_type":"moderate",
"phase":"active",
"expiry_time":"2099-01-01T00:00:00Z"
}]}"#;
let resp: BomWarningsResponse = serde_json::from_str(json).unwrap();
let alerts = bom_alerts_from_response(resp.data);
assert_eq!(alerts[0].area_desc, "");
}
#[tokio::test]
async fn dispatch_unknown_region_detailed_is_empty_and_filtered() {
let report = fetch_alerts_detailed(35.68, 139.65).await.unwrap();
assert!(report.alerts.is_empty());
assert!(report.region_filtered);
}
fn nuts3_entry(id: &str, nuts3: &str, area: &str) -> String {
format!(
r#"<entry>
<id>https://feeds.meteoalarm.org/feed/{id}</id>
<cap:geocode>
<valueName>NUTS3</valueName>
<value>{nuts3}</value>
</cap:geocode>
<link title="{area}" href="https://meteoalarm.org?geocode=EMMA_ID:FR031" hreflang="en"/>
<cap:areaDesc>{area}</cap:areaDesc>
<cap:event>Yellow Wind Warning</cap:event>
<cap:severity>Moderate</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
</entry>"#
)
}
#[test]
fn meteoalarm_nuts3_geocode_is_not_an_emma_id() {
let entry: MeteoAlarmEntry =
quick_xml::de::from_str(&nuts3_entry("fr-1", "FR713", "Drôme")).unwrap();
assert_eq!(entry_emma_id(&entry), None);
assert_eq!(entry.cap_geocode.unwrap().value.as_deref(), Some("FR713"));
}
#[test]
fn meteoalarm_feed_without_emma_ids_renders_unfiltered() {
let xml = format!(
"<feed>{}{}</feed>",
nuts3_entry("fr-1", "FR713", "Drôme"),
nuts3_entry("fr-2", "FR813", "Hérault")
);
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap();
let report = meteoalarm_alerts_from_feed(feed, &Some("FR101".to_string()), &[], "test");
assert_eq!(report.alerts.len(), 2);
assert!(!report.region_filtered);
assert_eq!(report.alerts[0].area_desc, "Drôme");
}
#[test]
fn meteoalarm_mixed_feed_drops_untagged_and_stays_filtered() {
let xml = format!(
r#"<feed>
<entry>
<id>https://feeds.meteoalarm.org/feed/tagged-here</id>
<cap:event>Wind</cap:event>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<value>FR101</value>
</cap:geocode>
</entry>
{}
</feed>"#,
nuts3_entry("fr-3", "FR713", "Drôme")
);
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap();
let report = meteoalarm_alerts_from_feed(feed, &Some("FR101".to_string()), &[], "test");
assert_eq!(report.alerts.len(), 1);
assert_eq!(
report.alerts[0].alert.id,
"https://feeds.meteoalarm.org/feed/tagged-here"
);
assert!(report.region_filtered);
}
#[test]
fn meteoalarm_empty_feed_with_filter_is_filtered() {
let feed: MeteoAlarmFeed = quick_xml::de::from_str("<feed></feed>").unwrap();
let report = meteoalarm_alerts_from_feed(feed, &Some("PL1465".to_string()), &[], "test");
assert!(report.alerts.is_empty());
assert!(report.region_filtered);
}
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn portugal_areas() -> Vec<String> {
strings(&[
"Beja",
"Bragança",
"Castelo Branco",
"Coimbra",
"Faro",
"Guarda",
"Leiria",
"Lisboa",
"Portalegre",
"Santarém",
"Setúbal",
"Vila Real",
"Viseu",
"Évora",
])
}
fn croatia_areas() -> Vec<String> {
strings(&[
"Dubrovnik region",
"Kvarner i Kvarneric region",
"Middle Dalmatia region",
"North Dalmatia region",
"Osijek region",
"Rijeka region",
"South Dalmatia region",
"Split region",
"Velebit channel region",
"West Istrian coast region",
"Zagreb region",
])
}
#[test]
fn area_tokens_folds_diacritics_and_affixes() {
assert_eq!(area_tokens("Évora"), strings(&["evora"]));
assert_eq!(area_tokens("Setúbal"), strings(&["setubal"]));
assert_eq!(area_tokens("Grad Zagreb"), strings(&["zagreb"]));
assert_eq!(area_tokens("Zagreb region"), strings(&["zagreb"]));
assert_eq!(
area_tokens("Brussel-Hoofdstad - Bruxelles-Capitale"),
strings(&["brussel", "hoofdstad", "bruxelles", "capitale"])
);
assert!(area_tokens("Kreis").is_empty());
}
#[test]
fn rank_area_match_exact_beats_tokens() {
let paris = area_tokens("Paris");
assert_eq!(
rank_area_match(&paris, &area_tokens("Paris")),
Some(AreaMatch::Exact)
);
assert_eq!(
rank_area_match(&paris, &area_tokens("Paris et Petite Ceinture")),
Some(AreaMatch::Tokens)
);
assert!(AreaMatch::Exact > AreaMatch::Tokens);
}
#[test]
fn rank_area_match_requires_an_anchor_token() {
assert_eq!(
rank_area_match(
&area_tokens("i"),
&area_tokens("Kvarner i Kvarneric region")
),
None
);
assert_eq!(rank_area_match(&[], &area_tokens("Faro")), None);
}
#[test]
fn rank_area_match_rejects_substrings() {
assert_eq!(
rank_area_match(&area_tokens("Seine"), &area_tokens("Seinemaritime")),
None
);
}
#[test]
fn match_area_zagreb() {
let terms = strings(&["Grad Zagreb", "Stadt Grad Zagreb"]);
assert_eq!(
match_area(&terms, &croatia_areas()).as_deref(),
Some("Zagreb region")
);
}
#[test]
fn match_area_lisboa_exact() {
let terms = strings(&[
"Lisboa",
"Stadt Lisboa",
"Arroios",
"Lisboa",
"Kreis Lisboa",
]);
assert_eq!(
match_area(&terms, &portugal_areas()).as_deref(),
Some("Lisboa")
);
}
#[test]
fn match_area_paris_prefers_exact() {
let areas = strings(&["Paris", "Paris et Petite Ceinture"]);
assert_eq!(
match_area(&strings(&["Paris"]), &areas).as_deref(),
Some("Paris")
);
}
#[test]
fn match_area_ambiguous_term_is_a_miss() {
let areas = strings(&["Seine-Maritime", "Seine-et-Marne", "Hauts-de-Seine"]);
assert_eq!(match_area(&strings(&["Seine"]), &areas), None);
}
#[test]
fn match_area_greek_script_misses_english_block() {
let areas = strings(&["Attiki", "Kriti", "Thessalia"]);
assert_eq!(
match_area(&strings(&["Αθήνα", "Περιφέρεια Αττικής"]), &areas),
None
);
}
#[test]
fn match_area_no_terms_is_a_miss() {
assert_eq!(match_area(&[], &portugal_areas()), None);
}
fn emma_entry(id: &str, emma_id: &str, area: &str) -> String {
format!(
r#"<entry>
<id>https://feeds.meteoalarm.org/feed/{id}</id>
<cap:geocode>
<valueName>EMMA_ID</valueName>
<value>{emma_id}</value>
</cap:geocode>
<cap:areaDesc>{area}</cap:areaDesc>
<cap:event>Yellow High Temperature Warning</cap:event>
<cap:severity>Moderate</cap:severity>
<cap:sent>2026-06-01T08:00:00Z</cap:sent>
<cap:expires>2099-01-01T00:00:00Z</cap:expires>
</entry>"#
)
}
fn portugal_feed() -> MeteoAlarmFeed {
let xml = format!(
"<feed>{}{}{}</feed>",
emma_entry("pt-1", "PT021", "Faro"),
emma_entry("pt-2", "PT013", "Lisboa"),
emma_entry("pt-3", "PT015", "Setúbal")
);
quick_xml::de::from_str(&xml).unwrap()
}
#[test]
fn portugal_user_in_lisboa_is_filtered_by_area() {
let terms = strings(&[
"Lisboa",
"Stadt Lisboa",
"Arroios",
"Lisboa",
"Kreis Lisboa",
]);
let report = meteoalarm_alerts_from_feed(portugal_feed(), &None, &terms, "Portugal");
assert_eq!(report.alerts.len(), 1);
assert_eq!(report.alerts[0].area_desc, "Lisboa");
assert!(report.region_filtered);
}
#[test]
fn portugal_user_in_porto_renders_national() {
let report =
meteoalarm_alerts_from_feed(portugal_feed(), &None, &strings(&["Porto"]), "Portugal");
assert_eq!(report.alerts.len(), 3);
assert!(!report.region_filtered);
}
#[test]
fn france_nuts3_feed_filters_by_area() {
let xml = format!(
"<feed>{}{}</feed>",
nuts3_entry("fr-1", "FR713", "Drôme"),
nuts3_entry("fr-2", "FR813", "Hérault")
);
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap();
let terms = strings(&["Valence", "Drôme"]);
let report =
meteoalarm_alerts_from_feed(feed, &Some("FR031".to_string()), &terms, "France");
assert_eq!(report.alerts.len(), 1);
assert_eq!(report.alerts[0].area_desc, "Drôme");
assert!(report.region_filtered);
}
#[test]
fn emma_id_quiet_day_stays_filtered_without_stage_two() {
let xml = format!(
"<feed>{}{}</feed>",
emma_entry("pl-1", "PL999", "Kraków"),
emma_entry("pl-2", "PL998", "Gdańsk")
);
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap();
let report = meteoalarm_alerts_from_feed(
feed,
&Some("PL1465".to_string()),
&strings(&["Warszawa"]),
"Polska",
);
assert!(report.alerts.is_empty());
assert!(report.region_filtered);
}
#[test]
fn emma_search_terms_includes_village_and_municipality() {
let mut address = address(Some("Lisboa"), Some("Lisboa"), None);
address.village = Some("Arroios".to_string());
address.municipality = Some("Lisboa".to_string());
let terms = emma_search_terms(&address);
assert_eq!(
terms,
vec![
"Lisboa",
"Stadt Lisboa",
"Arroios",
"Lisboa",
"Lisboa",
"Kreis Lisboa",
]
);
}
#[test]
fn cached_codenames_fills_then_reads() {
cache_codenames(&codenames(&[("PT021", "Faro")]));
let hit = cached_codenames().expect("cached after a successful fetch");
assert_eq!(hit.codes.get("PT021").map(String::as_str), Some("Faro"));
}
fn local_areas(pairs: &[(&str, &str)]) -> Vec<LocalArea> {
pairs
.iter()
.map(|(local, english)| LocalArea {
local: local.to_string(),
english: english.to_string(),
})
.collect()
}
fn greece_local_areas() -> Vec<LocalArea> {
local_areas(&[
("Ήπειρο", "Epirus"),
("Ανατολική Μακεδονία", "East Makedonia"),
("Ανατολική Πελοπόννησο", "East Peloponnisos"),
("Ανατολική Στερεά & Έυβοια", "East Sterea & Evvoia"),
("Αττική", "Attiki"),
("Δυτική Μακεδονία", "West Makedonia"),
("Δυτική Πελοπόννησο", "West Peloponnisos"),
("Δυτική Στερεά", "West Sterea"),
("Δωδεκάνησα΄", "Dodekanisa Islands"),
("Θεσσαλία", "Thessalia"),
("Θράκη", "Thraki"),
("Κεντρική Μακεδονία", "Central Makedonia"),
("Κρήτη", "Kriti"),
("Κυκλάδες", "Kyklades"),
(
"Νησιά Βορειοανατολικού Αιγαίου",
"North East Aegean Islands",
),
("Νησιά Ιονίου", "Ionion Islands"),
])
}
fn bulgaria_local_areas() -> Vec<LocalArea> {
local_areas(&[
("Благоевград", "Blagoevgrad"),
("Бургас", "Burgas"),
("Варна", "Varna"),
("Велико Търново", "Veliko Tarnovo"),
("Видин", "Vidin"),
("Враца", "Vratsa"),
("Габрово", "Gabrovo"),
("Добрич", "Dobrich"),
("Кърджали", "Kardzhali"),
("Кюстендил", "Kyustendil"),
("Ловеч", "Lovech"),
("Монтана", "Montana"),
("Пазарджик", "Pazardzhik"),
("Перник", "Pernik"),
("Плевен", "Pleven"),
("Пловдив", "Plovdiv"),
("Разград", "Razgrad"),
("Русе", "Ruse"),
("Силистра", "Silistra"),
("Сливен", "Sliven"),
("Смолян", "Smolyan"),
("Софийска област", "Sofia-region"),
("София град", "Sofia-city"),
("Стара Загора", "Stara Zagora"),
("Търговище", "Targovishte"),
("Хасково", "Haskovo"),
("Шумен", "Shumen"),
("Ямбол", "Yambol"),
])
}
fn local_names(areas: &[LocalArea]) -> Vec<String> {
areas.iter().map(|a| a.local.clone()).collect()
}
fn athens_terms() -> Vec<String> {
strings(&[
"Αθήνα",
"Stadt Αθήνα",
"Δήμος Αθηναίων",
"Περιφερειακή Ενότητα Κεντρικού Τομέα Αθηνών",
"Kreis Περιφερειακή Ενότητα Κεντρικού Τομέα Αθηνών",
"Περιφέρεια Αττικής",
])
}
fn sofia_terms() -> Vec<String> {
strings(&[
"София",
"Stadt София",
"Средец",
"Kreis Средец",
"София-град",
])
}
fn greece_report() -> AlertReport {
let xml = format!(
"<feed>{}{}{}</feed>",
emma_entry("gr-1", "GR001", "Attiki"),
emma_entry("gr-2", "GR002", "Kriti"),
emma_entry("gr-3", "GR003", "East Sterea &amp; Evvoia")
);
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap();
let report = meteoalarm_alerts_from_feed(feed, &None, &athens_terms(), "Ελλάς");
assert!(
!report.region_filtered,
"Greek terms cannot match the English block"
);
assert_eq!(report.alerts.len(), 3);
report
}
#[test]
fn is_latin_and_has_non_latin() {
assert!(!has_non_latin(&strings(&["Évora", "Łódź", "Setúbal"])));
assert!(has_non_latin(&strings(&["Αθήνα"])));
assert!(has_non_latin(&strings(&["София"])));
assert!(has_non_latin(&strings(&["ירושלים"])));
assert!(has_non_latin(&strings(&["Lisboa", "Αθήνα"])));
assert!(!has_non_latin(&[]));
}
#[test]
fn area_tokens_drops_greek_and_bulgarian_affixes() {
assert_eq!(area_tokens("Περιφέρεια Αττικής"), strings(&["αττικης"]));
assert_eq!(area_tokens("Δήμος Αθηναίων"), strings(&["αθηναιων"]));
assert_eq!(area_tokens("София град"), strings(&["софия"]));
assert_eq!(area_tokens("Софийска област"), strings(&["софииска"]));
}
#[test]
fn tokens_equal_genitive() {
assert!(tokens_equal("αττικη", "αττικης"));
assert!(tokens_equal("αττικης", "αττικη"));
assert!(tokens_equal("κρητη", "κρητης"));
assert!(!tokens_equal("αθηνα", "αθηναιων"));
assert!(!tokens_equal("paris", "parise"));
assert!(!tokens_equal("αττι", "αττικη"));
assert!(!tokens_equal("софия", "софииска"));
}
#[test]
fn match_area_athens_against_local_names() {
assert_eq!(
match_area(&athens_terms(), &local_names(&greece_local_areas())),
Some("Αττική".to_string())
);
}
#[test]
fn match_area_sofia_against_local_names() {
assert_eq!(
match_area(&sofia_terms(), &local_names(&bulgaria_local_areas())),
Some("София град".to_string())
);
}
#[test]
fn local_areas_from_json_pairs_blocks() {
let json = r#"{"warnings": [
{"alert": {"info": [
{"language": "en-GB", "area": [{"areaDesc": "Attiki"}]},
{"language": "el-GR", "area": [{"areaDesc": "Αττική"}]}
]}},
{"alert": {"info": [
{"language": "el-GR", "area": [{"areaDesc": "Κρήτη"}]},
{"language": "en-GB", "area": [{"areaDesc": "Kriti"}]}
]}},
{"alert": {"info": [
{"language": "en-GB", "area": [{"areaDesc": "Attiki"}]},
{"language": "el-GR", "area": [{"areaDesc": "Αττική"}]}
]}},
{"alert": {"info": [
{"language": "sr-Latn", "area": [{"areaDesc": "Beograd"}]},
{"language": "sr", "area": [{"areaDesc": "Београд"}]},
{"language": "en-GB", "area": [{"areaDesc": "Belgrade"}]}
]}},
{"alert": {"info": [
{"language": "en-GB", "area": [{}]},
{"language": "el-GR", "area": [{"areaDesc": ""}]}
]}},
{"alert": {}}
]}"#;
let feed: MeteoAlarmJsonFeed = serde_json::from_str(json).unwrap();
assert_eq!(
local_areas_from_json(feed),
local_areas(&[
("Beograd", "Belgrade"),
("Αττική", "Attiki"),
("Κρήτη", "Kriti"),
("Београд", "Belgrade"),
])
);
}
#[test]
fn apply_local_area_match_filters_by_english_name() {
let report = apply_local_area_match(
greece_report(),
&athens_terms(),
&greece_local_areas(),
"Ελλάς",
);
assert_eq!(report.alerts.len(), 1);
assert_eq!(report.alerts[0].area_desc, "Attiki");
assert!(report.region_filtered);
}
#[test]
fn apply_local_area_match_compares_parsed_area_desc_raw() {
let terms = strings(&["Ανατολική Στερεά"]);
let report =
apply_local_area_match(greece_report(), &terms, &greece_local_areas(), "Ελλάς");
assert_eq!(report.alerts.len(), 1);
assert_eq!(report.alerts[0].area_desc, "East Sterea & Evvoia");
assert!(report.region_filtered);
}
#[test]
fn apply_local_area_match_keeps_city_not_region() {
let xml = format!(
"<feed>{}{}{}</feed>",
nuts3_entry("bg-1", "BG411", "Sofia-city"),
nuts3_entry("bg-2", "BG412", "Sofia-region"),
nuts3_entry("bg-3", "BG413", "Blagoevgrad")
);
let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap();
let report = meteoalarm_alerts_from_feed(feed, &None, &sofia_terms(), "България");
assert!(!report.region_filtered);
let report =
apply_local_area_match(report, &sofia_terms(), &bulgaria_local_areas(), "България");
assert_eq!(report.alerts.len(), 1);
assert_eq!(report.alerts[0].area_desc, "Sofia-city");
assert!(report.region_filtered);
}
#[test]
fn apply_local_area_match_miss_leaves_report() {
let terms = strings(&["Θεσσαλονίκη"]);
let report =
apply_local_area_match(greece_report(), &terms, &greece_local_areas(), "Ελλάς");
assert_eq!(report.alerts.len(), 3);
assert!(!report.region_filtered);
}
#[test]
fn apply_local_area_match_skips_latin_only_inventory() {
let israel = local_areas(&[
("Judea Mountains", "Judea Mountains"),
("Gush Dan", "Gush Dan"),
]);
let terms = strings(&["ירושלים", "מחוז ירושלים"]);
let report = apply_local_area_match(greece_report(), &terms, &israel, "ישראל");
assert_eq!(report.alerts.len(), 3);
assert!(!report.region_filtered);
}
}