use crate::error::{DownloadError, Result};
use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DataFile {
pub service: String,
pub file_type: FileType,
pub update_type: UpdateType,
pub day: Option<Weekday>,
}
impl DataFile {
pub fn complete_license(service: impl Into<String>) -> Self {
Self {
service: service.into(),
file_type: FileType::License,
update_type: UpdateType::Complete,
day: None,
}
}
pub fn complete_application(service: impl Into<String>) -> Self {
Self {
service: service.into(),
file_type: FileType::Application,
update_type: UpdateType::Complete,
day: None,
}
}
pub fn daily_license(service: impl Into<String>, day: Weekday) -> Self {
Self {
service: service.into(),
file_type: FileType::License,
update_type: UpdateType::Daily,
day: Some(day),
}
}
pub fn filename(&self) -> String {
let prefix = match self.file_type {
FileType::License => "l",
FileType::Application => "a",
};
match self.update_type {
UpdateType::Complete => format!("{}_{}.zip", prefix, self.service),
UpdateType::Daily => {
let day_abbrev = self.day.map(|d| d.abbrev()).unwrap_or("mon");
let daily_service = ServiceCatalog::daily_abbreviation(&self.service);
format!("{}_{}_{}.zip", prefix, daily_service, day_abbrev)
}
}
}
pub fn url_path(&self) -> String {
match self.update_type {
UpdateType::Complete => format!("complete/{}", self.filename()),
UpdateType::Daily => format!("daily/{}", self.filename()),
}
}
}
impl fmt::Display for DataFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.filename())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FileType {
License,
Application,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum UpdateType {
Complete,
Daily,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Weekday {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
impl Weekday {
pub const ALL: [Weekday; 7] = [
Weekday::Sunday,
Weekday::Monday,
Weekday::Tuesday,
Weekday::Wednesday,
Weekday::Thursday,
Weekday::Friday,
Weekday::Saturday,
];
pub fn abbrev(&self) -> &'static str {
match self {
Weekday::Sunday => "sun",
Weekday::Monday => "mon",
Weekday::Tuesday => "tue",
Weekday::Wednesday => "wed",
Weekday::Thursday => "thu",
Weekday::Friday => "fri",
Weekday::Saturday => "sat",
}
}
pub fn from_chrono(day: chrono::Weekday) -> Self {
match day {
chrono::Weekday::Sun => Weekday::Sunday,
chrono::Weekday::Mon => Weekday::Monday,
chrono::Weekday::Tue => Weekday::Tuesday,
chrono::Weekday::Wed => Weekday::Wednesday,
chrono::Weekday::Thu => Weekday::Thursday,
chrono::Weekday::Fri => Weekday::Friday,
chrono::Weekday::Sat => Weekday::Saturday,
}
}
pub fn for_date(date: NaiveDate) -> Self {
Self::from_chrono(date.weekday())
}
}
pub struct ServiceCatalog;
impl ServiceCatalog {
const SERVICES: &'static [(
&'static str,
&'static str,
&'static str,
&'static [&'static str],
)] = &[
("amat", "am", "Amateur Radio", &["HA", "HV"]),
("gmrs", "gm", "General Mobile Radio Service", &["ZA"]),
("ship", "sh", "Ship Stations", &["SA", "SB"]),
("coast", "co", "Coastal Stations", &["MC"]),
("aircraft", "ac", "Aircraft Stations", &["AC"]),
("market", "mk", "Market Based Services", &[]),
("land", "ln", "Land Mobile", &[]),
("micro", "mi", "Microwave", &[]),
("paging", "pg", "Paging", &[]),
];
pub fn daily_abbreviation(service: &str) -> &'static str {
Self::SERVICES
.iter()
.find(|(full, _, _, _)| *full == service)
.map(|(_, abbrev, _, _)| *abbrev)
.unwrap_or("xx") }
pub fn full_name(input: &str) -> Option<&'static str> {
Self::SERVICES
.iter()
.find(|(full, daily, _, codes)| {
*full == input || *daily == input || codes.contains(&input)
})
.map(|(full, _, _, _)| *full)
}
pub fn all_services() -> Vec<ServiceInfo> {
Self::SERVICES
.iter()
.map(|(name, abbrev, desc, codes)| ServiceInfo {
name: name.to_string(),
daily_abbrev: abbrev.to_string(),
description: desc.to_string(),
radio_service_codes: codes.iter().map(|s| s.to_string()).collect(),
})
.collect()
}
pub fn is_known_service(service: &str) -> bool {
Self::SERVICES
.iter()
.any(|(full, daily, _, _)| *full == service || *daily == service)
}
pub fn complete_license(service: &str) -> Result<DataFile> {
let full_name = Self::full_name(service)
.ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
Ok(DataFile::complete_license(full_name))
}
pub fn complete_application(service: &str) -> Result<DataFile> {
let full_name = Self::full_name(service)
.ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
Ok(DataFile::complete_application(full_name))
}
pub fn daily_licenses(service: &str) -> Result<Vec<DataFile>> {
let full_name = Self::full_name(service)
.ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
Ok(Weekday::ALL
.iter()
.map(|day| DataFile::daily_license(full_name, *day))
.collect())
}
pub fn daily_license_for_date(service: &str, date: NaiveDate) -> Result<DataFile> {
let full_name = Self::full_name(service)
.ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
Ok(DataFile::daily_license(full_name, Weekday::for_date(date)))
}
pub fn daily_licenses_for_range(
service: &str,
start: NaiveDate,
end: NaiveDate,
) -> Result<Vec<(NaiveDate, DataFile)>> {
let full_name = Self::full_name(service)
.ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
let mut files = Vec::new();
let mut current = start;
while current <= end {
let weekday = Weekday::for_date(current);
files.push((current, DataFile::daily_license(full_name, weekday)));
current = current.succ_opt().unwrap_or(current);
}
Ok(files)
}
pub fn get_missing_daily_files(
service: &str,
last_weekly_date: NaiveDate,
applied_patch_dates: &[NaiveDate],
today: NaiveDate,
) -> Result<Vec<(NaiveDate, DataFile)>> {
let start = last_weekly_date.succ_opt().unwrap_or(last_weekly_date);
let all_files = Self::daily_licenses_for_range(service, start, today)?;
let applied_set: std::collections::HashSet<_> = applied_patch_dates.iter().collect();
let missing: Vec<_> = all_files
.into_iter()
.filter(|(date, _)| !applied_set.contains(date))
.collect();
Ok(missing)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceInfo {
pub name: String,
pub daily_abbrev: String,
pub description: String,
pub radio_service_codes: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_complete_license_filename() {
let file = DataFile::complete_license("amat");
assert_eq!(file.filename(), "l_amat.zip");
assert_eq!(file.url_path(), "complete/l_amat.zip");
}
#[test]
fn test_complete_application_filename() {
let file = DataFile::complete_application("amat");
assert_eq!(file.filename(), "a_amat.zip");
}
#[test]
fn test_daily_license_filename() {
let file = DataFile::daily_license("amat", Weekday::Monday);
assert_eq!(file.filename(), "l_am_mon.zip");
assert_eq!(file.url_path(), "daily/l_am_mon.zip");
}
#[test]
fn test_gmrs_files() {
let complete = DataFile::complete_license("gmrs");
assert_eq!(complete.filename(), "l_gmrs.zip");
let daily = DataFile::daily_license("gmrs", Weekday::Friday);
assert_eq!(daily.filename(), "l_gm_fri.zip");
}
#[test]
fn test_service_catalog() {
assert!(ServiceCatalog::is_known_service("amat"));
assert!(ServiceCatalog::is_known_service("am"));
assert!(ServiceCatalog::is_known_service("gmrs"));
assert!(!ServiceCatalog::is_known_service("unknown"));
}
#[test]
fn test_daily_abbreviation() {
assert_eq!(ServiceCatalog::daily_abbreviation("amat"), "am");
assert_eq!(ServiceCatalog::daily_abbreviation("gmrs"), "gm");
}
#[test]
fn test_all_services() {
let services = ServiceCatalog::all_services();
assert!(services.iter().any(|s| s.name == "amat"));
assert!(services.iter().any(|s| s.name == "gmrs"));
}
#[test]
fn test_radio_service_code_lookup() {
assert_eq!(ServiceCatalog::full_name("HA"), Some("amat"));
assert_eq!(ServiceCatalog::full_name("HV"), Some("amat"));
assert_eq!(ServiceCatalog::full_name("ZA"), Some("gmrs"));
}
#[test]
fn test_complete_license_by_radio_service_code() {
let file = ServiceCatalog::complete_license("HA").expect("HA should be recognized");
assert_eq!(file.filename(), "l_amat.zip");
let file = ServiceCatalog::complete_license("ZA").expect("ZA should be recognized");
assert_eq!(file.filename(), "l_gmrs.zip");
}
#[test]
fn test_complete_license_by_full_name() {
let file = ServiceCatalog::complete_license("amat").expect("amat should be recognized");
assert_eq!(file.filename(), "l_amat.zip");
}
#[test]
fn test_unknown_service() {
assert!(ServiceCatalog::complete_license("UNKNOWN").is_err());
}
#[test]
fn test_daily_licenses_for_range() {
let start = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 1, 16).unwrap();
let files = ServiceCatalog::daily_licenses_for_range("amat", start, end).unwrap();
assert_eq!(files.len(), 5);
assert_eq!(files[0].1.filename(), "l_am_mon.zip");
assert_eq!(files[4].1.filename(), "l_am_fri.zip");
}
#[test]
fn test_daily_licenses_for_range_includes_sunday() {
let start = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
let files = ServiceCatalog::daily_licenses_for_range("amat", start, end).unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].0, NaiveDate::from_ymd_opt(2026, 1, 11).unwrap());
assert_eq!(files[1].0, NaiveDate::from_ymd_opt(2026, 1, 12).unwrap());
}
#[test]
fn test_get_missing_daily_files() {
let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
assert_eq!(missing.len(), 4);
assert_eq!(missing[0].1.filename(), "l_am_mon.zip");
assert_eq!(missing[3].1.filename(), "l_am_thu.zip");
}
#[test]
fn test_get_missing_daily_files_with_applied() {
let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let applied = vec![
NaiveDate::from_ymd_opt(2026, 1, 12).unwrap(),
NaiveDate::from_ymd_opt(2026, 1, 13).unwrap(),
];
let missing =
ServiceCatalog::get_missing_daily_files("amat", weekly, &applied, today).unwrap();
assert_eq!(missing.len(), 2);
assert_eq!(missing[0].1.filename(), "l_am_wed.zip");
assert_eq!(missing[1].1.filename(), "l_am_thu.zip");
}
#[test]
fn test_get_missing_daily_files_on_sunday() {
let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 1, 18).unwrap();
let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
assert_eq!(missing.len(), 7);
}
#[test]
fn test_sunday_weekday() {
assert_eq!(Weekday::Sunday.abbrev(), "sun");
assert_eq!(Weekday::from_chrono(chrono::Weekday::Sun), Weekday::Sunday);
let sunday = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
assert_eq!(Weekday::for_date(sunday), Weekday::Sunday);
}
#[test]
fn test_daily_license_for_date_sunday() {
let sunday = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let file = ServiceCatalog::daily_license_for_date("amat", sunday).unwrap();
assert_eq!(file.filename(), "l_am_sun.zip");
}
#[test]
fn test_daily_licenses_for_full_week() {
let start = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 1, 17).unwrap();
let files = ServiceCatalog::daily_licenses_for_range("amat", start, end).unwrap();
assert_eq!(files.len(), 7);
assert_eq!(files[0].1.filename(), "l_am_sun.zip");
assert_eq!(files[6].1.filename(), "l_am_sat.zip");
}
#[test]
fn test_datafile_display_uses_filename() {
let complete = DataFile::complete_license("amat");
assert_eq!(complete.to_string(), "l_amat.zip");
let daily = DataFile::daily_license("gmrs", Weekday::Wednesday);
assert_eq!(daily.to_string(), "l_gm_wed.zip");
let application = DataFile::complete_application("amat");
assert_eq!(application.to_string(), "a_amat.zip");
}
#[test]
fn test_complete_application_via_catalog_known_and_unknown() {
let file = ServiceCatalog::complete_application("amat").unwrap();
assert_eq!(file.filename(), "a_amat.zip");
assert_eq!(file.url_path(), "complete/a_amat.zip");
let by_code = ServiceCatalog::complete_application("HA").unwrap();
assert_eq!(by_code.filename(), "a_amat.zip");
let err = ServiceCatalog::complete_application("nope").unwrap_err();
assert!(matches!(err, DownloadError::UnknownService(s) if s == "nope"));
}
#[test]
fn test_full_name_unknown_returns_none() {
assert_eq!(ServiceCatalog::full_name("amat"), Some("amat"));
assert_eq!(ServiceCatalog::full_name("gm"), Some("gmrs"));
assert_eq!(ServiceCatalog::full_name("ZA"), Some("gmrs"));
assert_eq!(ServiceCatalog::full_name("not-a-service"), None);
}
#[test]
fn test_daily_abbreviation_unknown_returns_placeholder() {
assert_eq!(ServiceCatalog::daily_abbreviation("ship"), "sh");
assert_eq!(ServiceCatalog::daily_abbreviation("does-not-exist"), "xx");
}
#[test]
fn test_unknown_service_daily_filename_uses_placeholder() {
let file = DataFile::daily_license("mystery", Weekday::Tuesday);
assert_eq!(file.filename(), "l_xx_tue.zip");
}
#[test]
fn test_all_services_metadata() {
let services = ServiceCatalog::all_services();
assert_eq!(services.len(), 9);
let amat = services.iter().find(|s| s.name == "amat").unwrap();
assert_eq!(amat.daily_abbrev, "am");
assert_eq!(amat.description, "Amateur Radio");
assert_eq!(amat.radio_service_codes, vec!["HA", "HV"]);
let market = services.iter().find(|s| s.name == "market").unwrap();
assert!(market.radio_service_codes.is_empty());
}
#[test]
fn test_daily_licenses_lists_all_seven_days() {
let files = ServiceCatalog::daily_licenses("amat").unwrap();
let names: Vec<String> = files.iter().map(|f| f.filename()).collect();
assert_eq!(
names,
vec![
"l_am_sun.zip",
"l_am_mon.zip",
"l_am_tue.zip",
"l_am_wed.zip",
"l_am_thu.zip",
"l_am_fri.zip",
"l_am_sat.zip",
]
);
assert!(ServiceCatalog::daily_licenses("nope").is_err());
}
#[test]
fn test_weekday_all_ordering_and_abbrevs() {
assert_eq!(Weekday::ALL.len(), 7);
let abbrevs: Vec<&str> = Weekday::ALL.iter().map(|d| d.abbrev()).collect();
assert_eq!(
abbrevs,
vec!["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
);
}
#[rstest::rstest]
#[case(chrono::Weekday::Sun, Weekday::Sunday, "sun")]
#[case(chrono::Weekday::Mon, Weekday::Monday, "mon")]
#[case(chrono::Weekday::Tue, Weekday::Tuesday, "tue")]
#[case(chrono::Weekday::Wed, Weekday::Wednesday, "wed")]
#[case(chrono::Weekday::Thu, Weekday::Thursday, "thu")]
#[case(chrono::Weekday::Fri, Weekday::Friday, "fri")]
#[case(chrono::Weekday::Sat, Weekday::Saturday, "sat")]
fn test_weekday_from_chrono_and_abbrev(
#[case] chrono_day: chrono::Weekday,
#[case] expected: Weekday,
#[case] abbrev: &str,
) {
assert_eq!(Weekday::from_chrono(chrono_day), expected);
assert_eq!(expected.abbrev(), abbrev);
}
#[rstest::rstest]
#[case(11, Weekday::Sunday)]
#[case(12, Weekday::Monday)]
#[case(13, Weekday::Tuesday)]
#[case(14, Weekday::Wednesday)]
#[case(15, Weekday::Thursday)]
#[case(16, Weekday::Friday)]
#[case(17, Weekday::Saturday)]
fn test_weekday_for_date(#[case] day_of_month: u32, #[case] expected: Weekday) {
let date = NaiveDate::from_ymd_opt(2026, 1, day_of_month).unwrap();
assert_eq!(Weekday::for_date(date), expected);
}
#[test]
fn test_get_missing_daily_files_unknown_service_errors() {
let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
assert!(ServiceCatalog::get_missing_daily_files("nope", weekly, &[], today).is_err());
}
#[test]
fn test_get_missing_daily_files_none_when_caught_up() {
let weekly = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
assert!(missing.is_empty());
}
#[test]
fn test_get_missing_daily_files_second_week_with_first_week_applied() {
let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
let today = NaiveDate::from_ymd_opt(2026, 1, 22).unwrap();
let applied = vec![
NaiveDate::from_ymd_opt(2026, 1, 12).unwrap(), NaiveDate::from_ymd_opt(2026, 1, 13).unwrap(), NaiveDate::from_ymd_opt(2026, 1, 14).unwrap(), NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), NaiveDate::from_ymd_opt(2026, 1, 16).unwrap(), NaiveDate::from_ymd_opt(2026, 1, 17).unwrap(), NaiveDate::from_ymd_opt(2026, 1, 18).unwrap(), ];
let missing =
ServiceCatalog::get_missing_daily_files("amat", weekly, &applied, today).unwrap();
assert_eq!(missing.len(), 4);
assert_eq!(missing[0].0, NaiveDate::from_ymd_opt(2026, 1, 19).unwrap());
assert_eq!(missing[3].0, NaiveDate::from_ymd_opt(2026, 1, 22).unwrap());
}
}