1use crate::error::{DownloadError, Result};
6use chrono::{Datelike, NaiveDate};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct DataFile {
13 pub service: String,
15
16 pub file_type: FileType,
18
19 pub update_type: UpdateType,
21
22 pub day: Option<Weekday>,
24}
25
26impl DataFile {
27 pub fn complete_license(service: impl Into<String>) -> Self {
29 Self {
30 service: service.into(),
31 file_type: FileType::License,
32 update_type: UpdateType::Complete,
33 day: None,
34 }
35 }
36
37 pub fn complete_application(service: impl Into<String>) -> Self {
39 Self {
40 service: service.into(),
41 file_type: FileType::Application,
42 update_type: UpdateType::Complete,
43 day: None,
44 }
45 }
46
47 pub fn daily_license(service: impl Into<String>, day: Weekday) -> Self {
49 Self {
50 service: service.into(),
51 file_type: FileType::License,
52 update_type: UpdateType::Daily,
53 day: Some(day),
54 }
55 }
56
57 pub fn filename(&self) -> String {
59 let prefix = match self.file_type {
60 FileType::License => "l",
61 FileType::Application => "a",
62 };
63
64 match self.update_type {
65 UpdateType::Complete => format!("{}_{}.zip", prefix, self.service),
66 UpdateType::Daily => {
67 let day_abbrev = self.day.map(|d| d.abbrev()).unwrap_or("mon");
68 let daily_service = ServiceCatalog::daily_abbreviation(&self.service);
70 format!("{}_{}_{}.zip", prefix, daily_service, day_abbrev)
71 }
72 }
73 }
74
75 pub fn url_path(&self) -> String {
77 match self.update_type {
78 UpdateType::Complete => format!("complete/{}", self.filename()),
79 UpdateType::Daily => format!("daily/{}", self.filename()),
80 }
81 }
82}
83
84impl fmt::Display for DataFile {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 write!(f, "{}", self.filename())
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92pub enum FileType {
93 License,
95 Application,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub enum UpdateType {
102 Complete,
104 Daily,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum Weekday {
111 Sunday,
112 Monday,
113 Tuesday,
114 Wednesday,
115 Thursday,
116 Friday,
117 Saturday,
118}
119
120impl Weekday {
121 pub const ALL: [Weekday; 7] = [
123 Weekday::Sunday,
124 Weekday::Monday,
125 Weekday::Tuesday,
126 Weekday::Wednesday,
127 Weekday::Thursday,
128 Weekday::Friday,
129 Weekday::Saturday,
130 ];
131
132 pub fn abbrev(&self) -> &'static str {
134 match self {
135 Weekday::Sunday => "sun",
136 Weekday::Monday => "mon",
137 Weekday::Tuesday => "tue",
138 Weekday::Wednesday => "wed",
139 Weekday::Thursday => "thu",
140 Weekday::Friday => "fri",
141 Weekday::Saturday => "sat",
142 }
143 }
144
145 pub fn from_chrono(day: chrono::Weekday) -> Self {
147 match day {
148 chrono::Weekday::Sun => Weekday::Sunday,
149 chrono::Weekday::Mon => Weekday::Monday,
150 chrono::Weekday::Tue => Weekday::Tuesday,
151 chrono::Weekday::Wed => Weekday::Wednesday,
152 chrono::Weekday::Thu => Weekday::Thursday,
153 chrono::Weekday::Fri => Weekday::Friday,
154 chrono::Weekday::Sat => Weekday::Saturday,
155 }
156 }
157
158 pub fn for_date(date: NaiveDate) -> Self {
160 Self::from_chrono(date.weekday())
161 }
162}
163
164pub struct ServiceCatalog;
166
167impl ServiceCatalog {
168 const SERVICES: &'static [(
171 &'static str,
172 &'static str,
173 &'static str,
174 &'static [&'static str],
175 )] = &[
176 ("amat", "am", "Amateur Radio", &["HA", "HV"]),
177 ("gmrs", "gm", "General Mobile Radio Service", &["ZA"]),
178 ("ship", "sh", "Ship Stations", &["SA", "SB"]),
179 ("coast", "co", "Coastal Stations", &["MC"]),
180 ("aircraft", "ac", "Aircraft Stations", &["AC"]),
181 ("market", "mk", "Market Based Services", &[]),
182 ("land", "ln", "Land Mobile", &[]),
183 ("micro", "mi", "Microwave", &[]),
184 ("paging", "pg", "Paging", &[]),
185 ];
186
187 pub fn daily_abbreviation(service: &str) -> &'static str {
189 Self::SERVICES
190 .iter()
191 .find(|(full, _, _, _)| *full == service)
192 .map(|(_, abbrev, _, _)| *abbrev)
193 .unwrap_or("xx") }
195
196 pub fn full_name(input: &str) -> Option<&'static str> {
199 Self::SERVICES
200 .iter()
201 .find(|(full, daily, _, codes)| {
202 *full == input || *daily == input || codes.contains(&input)
203 })
204 .map(|(full, _, _, _)| *full)
205 }
206
207 pub fn all_services() -> Vec<ServiceInfo> {
209 Self::SERVICES
210 .iter()
211 .map(|(name, abbrev, desc, codes)| ServiceInfo {
212 name: name.to_string(),
213 daily_abbrev: abbrev.to_string(),
214 description: desc.to_string(),
215 radio_service_codes: codes.iter().map(|s| s.to_string()).collect(),
216 })
217 .collect()
218 }
219
220 pub fn is_known_service(service: &str) -> bool {
222 Self::SERVICES
223 .iter()
224 .any(|(full, daily, _, _)| *full == service || *daily == service)
225 }
226
227 pub fn complete_license(service: &str) -> Result<DataFile> {
229 let full_name = Self::full_name(service)
230 .ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
231 Ok(DataFile::complete_license(full_name))
232 }
233
234 pub fn complete_application(service: &str) -> Result<DataFile> {
236 let full_name = Self::full_name(service)
237 .ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
238 Ok(DataFile::complete_application(full_name))
239 }
240
241 pub fn daily_licenses(service: &str) -> Result<Vec<DataFile>> {
243 let full_name = Self::full_name(service)
244 .ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
245
246 Ok(Weekday::ALL
247 .iter()
248 .map(|day| DataFile::daily_license(full_name, *day))
249 .collect())
250 }
251
252 pub fn daily_license_for_date(service: &str, date: NaiveDate) -> Result<DataFile> {
254 let full_name = Self::full_name(service)
255 .ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
256
257 Ok(DataFile::daily_license(full_name, Weekday::for_date(date)))
258 }
259
260 pub fn daily_licenses_for_range(
262 service: &str,
263 start: NaiveDate,
264 end: NaiveDate,
265 ) -> Result<Vec<(NaiveDate, DataFile)>> {
266 let full_name = Self::full_name(service)
267 .ok_or_else(|| DownloadError::UnknownService(service.to_string()))?;
268
269 let mut files = Vec::new();
270 let mut current = start;
271
272 while current <= end {
273 let weekday = Weekday::for_date(current);
274 files.push((current, DataFile::daily_license(full_name, weekday)));
275 current = current.succ_opt().unwrap_or(current);
276 }
277
278 Ok(files)
279 }
280
281 pub fn get_missing_daily_files(
286 service: &str,
287 last_weekly_date: NaiveDate,
288 applied_patch_dates: &[NaiveDate],
289 today: NaiveDate,
290 ) -> Result<Vec<(NaiveDate, DataFile)>> {
291 let start = last_weekly_date.succ_opt().unwrap_or(last_weekly_date);
293
294 let all_files = Self::daily_licenses_for_range(service, start, today)?;
296
297 let applied_set: std::collections::HashSet<_> = applied_patch_dates.iter().collect();
299 let missing: Vec<_> = all_files
300 .into_iter()
301 .filter(|(date, _)| !applied_set.contains(date))
302 .collect();
303
304 Ok(missing)
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ServiceInfo {
311 pub name: String,
313 pub daily_abbrev: String,
315 pub description: String,
317 pub radio_service_codes: Vec<String>,
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn test_complete_license_filename() {
327 let file = DataFile::complete_license("amat");
328 assert_eq!(file.filename(), "l_amat.zip");
329 assert_eq!(file.url_path(), "complete/l_amat.zip");
330 }
331
332 #[test]
333 fn test_complete_application_filename() {
334 let file = DataFile::complete_application("amat");
335 assert_eq!(file.filename(), "a_amat.zip");
336 }
337
338 #[test]
339 fn test_daily_license_filename() {
340 let file = DataFile::daily_license("amat", Weekday::Monday);
341 assert_eq!(file.filename(), "l_am_mon.zip");
342 assert_eq!(file.url_path(), "daily/l_am_mon.zip");
343 }
344
345 #[test]
346 fn test_gmrs_files() {
347 let complete = DataFile::complete_license("gmrs");
348 assert_eq!(complete.filename(), "l_gmrs.zip");
349
350 let daily = DataFile::daily_license("gmrs", Weekday::Friday);
351 assert_eq!(daily.filename(), "l_gm_fri.zip");
352 }
353
354 #[test]
355 fn test_service_catalog() {
356 assert!(ServiceCatalog::is_known_service("amat"));
357 assert!(ServiceCatalog::is_known_service("am"));
358 assert!(ServiceCatalog::is_known_service("gmrs"));
359 assert!(!ServiceCatalog::is_known_service("unknown"));
360 }
361
362 #[test]
363 fn test_daily_abbreviation() {
364 assert_eq!(ServiceCatalog::daily_abbreviation("amat"), "am");
365 assert_eq!(ServiceCatalog::daily_abbreviation("gmrs"), "gm");
366 }
367
368 #[test]
369 fn test_all_services() {
370 let services = ServiceCatalog::all_services();
371 assert!(services.iter().any(|s| s.name == "amat"));
372 assert!(services.iter().any(|s| s.name == "gmrs"));
373 }
374
375 #[test]
376 fn test_radio_service_code_lookup() {
377 assert_eq!(ServiceCatalog::full_name("HA"), Some("amat"));
379 assert_eq!(ServiceCatalog::full_name("HV"), Some("amat"));
380 assert_eq!(ServiceCatalog::full_name("ZA"), Some("gmrs"));
381 }
382
383 #[test]
384 fn test_complete_license_by_radio_service_code() {
385 let file = ServiceCatalog::complete_license("HA").expect("HA should be recognized");
387 assert_eq!(file.filename(), "l_amat.zip");
388
389 let file = ServiceCatalog::complete_license("ZA").expect("ZA should be recognized");
390 assert_eq!(file.filename(), "l_gmrs.zip");
391 }
392
393 #[test]
394 fn test_complete_license_by_full_name() {
395 let file = ServiceCatalog::complete_license("amat").expect("amat should be recognized");
396 assert_eq!(file.filename(), "l_amat.zip");
397 }
398
399 #[test]
400 fn test_unknown_service() {
401 assert!(ServiceCatalog::complete_license("UNKNOWN").is_err());
402 }
403
404 #[test]
405 fn test_daily_licenses_for_range() {
406 let start = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
408 let end = NaiveDate::from_ymd_opt(2026, 1, 16).unwrap();
409
410 let files = ServiceCatalog::daily_licenses_for_range("amat", start, end).unwrap();
411
412 assert_eq!(files.len(), 5);
413 assert_eq!(files[0].1.filename(), "l_am_mon.zip");
414 assert_eq!(files[4].1.filename(), "l_am_fri.zip");
415 }
416
417 #[test]
418 fn test_daily_licenses_for_range_includes_sunday() {
419 let start = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
421 let end = NaiveDate::from_ymd_opt(2026, 1, 12).unwrap();
422
423 let files = ServiceCatalog::daily_licenses_for_range("amat", start, end).unwrap();
424
425 assert_eq!(files.len(), 2);
426 assert_eq!(files[0].0, NaiveDate::from_ymd_opt(2026, 1, 11).unwrap());
427 assert_eq!(files[1].0, NaiveDate::from_ymd_opt(2026, 1, 12).unwrap());
428 }
429
430 #[test]
431 fn test_get_missing_daily_files() {
432 let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
434 let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
435
436 let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
438
439 assert_eq!(missing.len(), 4);
441 assert_eq!(missing[0].1.filename(), "l_am_mon.zip");
442 assert_eq!(missing[3].1.filename(), "l_am_thu.zip");
443 }
444
445 #[test]
446 fn test_get_missing_daily_files_with_applied() {
447 let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
448 let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
449
450 let applied = vec![
452 NaiveDate::from_ymd_opt(2026, 1, 12).unwrap(),
453 NaiveDate::from_ymd_opt(2026, 1, 13).unwrap(),
454 ];
455
456 let missing =
457 ServiceCatalog::get_missing_daily_files("amat", weekly, &applied, today).unwrap();
458
459 assert_eq!(missing.len(), 2);
461 assert_eq!(missing[0].1.filename(), "l_am_wed.zip");
462 assert_eq!(missing[1].1.filename(), "l_am_thu.zip");
463 }
464
465 #[test]
466 fn test_get_missing_daily_files_on_sunday() {
467 let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
468 let today = NaiveDate::from_ymd_opt(2026, 1, 18).unwrap(); let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
471
472 assert_eq!(missing.len(), 7);
474 }
475
476 #[test]
477 fn test_sunday_weekday() {
478 assert_eq!(Weekday::Sunday.abbrev(), "sun");
479 assert_eq!(Weekday::from_chrono(chrono::Weekday::Sun), Weekday::Sunday);
480
481 let sunday = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
482 assert_eq!(Weekday::for_date(sunday), Weekday::Sunday);
483 }
484
485 #[test]
486 fn test_daily_license_for_date_sunday() {
487 let sunday = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
488 let file = ServiceCatalog::daily_license_for_date("amat", sunday).unwrap();
489 assert_eq!(file.filename(), "l_am_sun.zip");
490 }
491
492 #[test]
493 fn test_daily_licenses_for_full_week() {
494 let start = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
496 let end = NaiveDate::from_ymd_opt(2026, 1, 17).unwrap();
497
498 let files = ServiceCatalog::daily_licenses_for_range("amat", start, end).unwrap();
499
500 assert_eq!(files.len(), 7);
501 assert_eq!(files[0].1.filename(), "l_am_sun.zip");
502 assert_eq!(files[6].1.filename(), "l_am_sat.zip");
503 }
504
505 #[test]
506 fn test_datafile_display_uses_filename() {
507 let complete = DataFile::complete_license("amat");
508 assert_eq!(complete.to_string(), "l_amat.zip");
509
510 let daily = DataFile::daily_license("gmrs", Weekday::Wednesday);
511 assert_eq!(daily.to_string(), "l_gm_wed.zip");
512
513 let application = DataFile::complete_application("amat");
514 assert_eq!(application.to_string(), "a_amat.zip");
515 }
516
517 #[test]
518 fn test_complete_application_via_catalog_known_and_unknown() {
519 let file = ServiceCatalog::complete_application("amat").unwrap();
520 assert_eq!(file.filename(), "a_amat.zip");
521 assert_eq!(file.url_path(), "complete/a_amat.zip");
522
523 let by_code = ServiceCatalog::complete_application("HA").unwrap();
525 assert_eq!(by_code.filename(), "a_amat.zip");
526
527 let err = ServiceCatalog::complete_application("nope").unwrap_err();
528 assert!(matches!(err, DownloadError::UnknownService(s) if s == "nope"));
529 }
530
531 #[test]
532 fn test_full_name_unknown_returns_none() {
533 assert_eq!(ServiceCatalog::full_name("amat"), Some("amat"));
534 assert_eq!(ServiceCatalog::full_name("gm"), Some("gmrs"));
535 assert_eq!(ServiceCatalog::full_name("ZA"), Some("gmrs"));
536 assert_eq!(ServiceCatalog::full_name("not-a-service"), None);
537 }
538
539 #[test]
540 fn test_daily_abbreviation_unknown_returns_placeholder() {
541 assert_eq!(ServiceCatalog::daily_abbreviation("ship"), "sh");
542 assert_eq!(ServiceCatalog::daily_abbreviation("does-not-exist"), "xx");
543 }
544
545 #[test]
546 fn test_unknown_service_daily_filename_uses_placeholder() {
547 let file = DataFile::daily_license("mystery", Weekday::Tuesday);
549 assert_eq!(file.filename(), "l_xx_tue.zip");
550 }
551
552 #[test]
553 fn test_all_services_metadata() {
554 let services = ServiceCatalog::all_services();
555 assert_eq!(services.len(), 9);
556
557 let amat = services.iter().find(|s| s.name == "amat").unwrap();
558 assert_eq!(amat.daily_abbrev, "am");
559 assert_eq!(amat.description, "Amateur Radio");
560 assert_eq!(amat.radio_service_codes, vec!["HA", "HV"]);
561
562 let market = services.iter().find(|s| s.name == "market").unwrap();
563 assert!(market.radio_service_codes.is_empty());
564 }
565
566 #[test]
567 fn test_daily_licenses_lists_all_seven_days() {
568 let files = ServiceCatalog::daily_licenses("amat").unwrap();
569 let names: Vec<String> = files.iter().map(|f| f.filename()).collect();
570 assert_eq!(
571 names,
572 vec![
573 "l_am_sun.zip",
574 "l_am_mon.zip",
575 "l_am_tue.zip",
576 "l_am_wed.zip",
577 "l_am_thu.zip",
578 "l_am_fri.zip",
579 "l_am_sat.zip",
580 ]
581 );
582
583 assert!(ServiceCatalog::daily_licenses("nope").is_err());
584 }
585
586 #[test]
587 fn test_weekday_all_ordering_and_abbrevs() {
588 assert_eq!(Weekday::ALL.len(), 7);
589 let abbrevs: Vec<&str> = Weekday::ALL.iter().map(|d| d.abbrev()).collect();
590 assert_eq!(
591 abbrevs,
592 vec!["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
593 );
594 }
595
596 #[rstest::rstest]
597 #[case(chrono::Weekday::Sun, Weekday::Sunday, "sun")]
598 #[case(chrono::Weekday::Mon, Weekday::Monday, "mon")]
599 #[case(chrono::Weekday::Tue, Weekday::Tuesday, "tue")]
600 #[case(chrono::Weekday::Wed, Weekday::Wednesday, "wed")]
601 #[case(chrono::Weekday::Thu, Weekday::Thursday, "thu")]
602 #[case(chrono::Weekday::Fri, Weekday::Friday, "fri")]
603 #[case(chrono::Weekday::Sat, Weekday::Saturday, "sat")]
604 fn test_weekday_from_chrono_and_abbrev(
605 #[case] chrono_day: chrono::Weekday,
606 #[case] expected: Weekday,
607 #[case] abbrev: &str,
608 ) {
609 assert_eq!(Weekday::from_chrono(chrono_day), expected);
610 assert_eq!(expected.abbrev(), abbrev);
611 }
612
613 #[rstest::rstest]
614 #[case(11, Weekday::Sunday)]
616 #[case(12, Weekday::Monday)]
617 #[case(13, Weekday::Tuesday)]
618 #[case(14, Weekday::Wednesday)]
619 #[case(15, Weekday::Thursday)]
620 #[case(16, Weekday::Friday)]
621 #[case(17, Weekday::Saturday)]
622 fn test_weekday_for_date(#[case] day_of_month: u32, #[case] expected: Weekday) {
623 let date = NaiveDate::from_ymd_opt(2026, 1, day_of_month).unwrap();
624 assert_eq!(Weekday::for_date(date), expected);
625 }
626
627 #[test]
628 fn test_get_missing_daily_files_unknown_service_errors() {
629 let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
630 let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
631 assert!(ServiceCatalog::get_missing_daily_files("nope", weekly, &[], today).is_err());
632 }
633
634 #[test]
635 fn test_get_missing_daily_files_none_when_caught_up() {
636 let weekly = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
638 let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
639 let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
640 assert!(missing.is_empty());
641 }
642
643 #[test]
644 fn test_get_missing_daily_files_second_week_with_first_week_applied() {
645 let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
647 let today = NaiveDate::from_ymd_opt(2026, 1, 22).unwrap();
648
649 let applied = vec![
650 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(), ];
658
659 let missing =
660 ServiceCatalog::get_missing_daily_files("amat", weekly, &applied, today).unwrap();
661
662 assert_eq!(missing.len(), 4);
664 assert_eq!(missing[0].0, NaiveDate::from_ymd_opt(2026, 1, 19).unwrap());
665 assert_eq!(missing[3].0, NaiveDate::from_ymd_opt(2026, 1, 22).unwrap());
666 }
667}