Skip to main content

uls_download/
catalog.rs

1//! FCC ULS service and file catalog.
2//!
3//! Maps radio service codes to their corresponding FCC download files.
4
5use crate::error::{DownloadError, Result};
6use chrono::{Datelike, NaiveDate};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10/// A downloadable FCC ULS data file.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct DataFile {
13    /// The service abbreviation (e.g., "amat", "gmrs").
14    pub service: String,
15
16    /// The file type (license or application).
17    pub file_type: FileType,
18
19    /// The update type (complete or daily).
20    pub update_type: UpdateType,
21
22    /// For daily files, the day of week. None for complete files.
23    pub day: Option<Weekday>,
24}
25
26impl DataFile {
27    /// Create a new complete (weekly) license file.
28    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    /// Create a new complete (weekly) application file.
38    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    /// Create a new daily license file.
48    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    /// Get the filename for this data file.
58    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                // Daily files use abbreviated service names
69                let daily_service = ServiceCatalog::daily_abbreviation(&self.service);
70                format!("{}_{}_{}.zip", prefix, daily_service, day_abbrev)
71            }
72        }
73    }
74
75    /// Get the URL path for this data file (without base URL).
76    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/// Type of data file (license or application).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92pub enum FileType {
93    /// License data (l_*.zip).
94    License,
95    /// Application data (a_*.zip).
96    Application,
97}
98
99/// Type of update (complete weekly or daily incremental).
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub enum UpdateType {
102    /// Complete weekly database.
103    Complete,
104    /// Daily transaction file.
105    Daily,
106}
107
108/// Day of week for daily files.
109#[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    /// Get all days of the week.
122    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    /// Get the three-letter abbreviation.
133    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    /// Create from chrono::Weekday.
146    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    /// Get the weekday for a given date.
159    pub fn for_date(date: NaiveDate) -> Self {
160        Self::from_chrono(date.weekday())
161    }
162}
163
164/// Catalog of FCC ULS services and their corresponding files.
165pub struct ServiceCatalog;
166
167impl ServiceCatalog {
168    /// All supported services with their full and daily abbreviations.
169    /// Format: (full_name, daily_abbreviation, description, radio_service_codes)
170    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    /// Get the daily abbreviation for a service.
188    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") // Unknown services get placeholder
194    }
195
196    /// Get the full service name from an abbreviation or radio service code.
197    /// Accepts: full name ("amat"), daily abbrev ("am"), or radio service code ("HA").
198    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    /// Get all available services.
208    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    /// Check if a service is known.
221    pub fn is_known_service(service: &str) -> bool {
222        Self::SERVICES
223            .iter()
224            .any(|(full, daily, _, _)| *full == service || *daily == service)
225    }
226
227    /// Get complete license file for a service.
228    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    /// Get complete application file for a service.
235    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    /// Get all daily license files for a service.
242    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    /// Get the daily license file for a specific date.
253    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    /// Get daily license files for a date range (inclusive).
261    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    /// Calculate which daily files are needed to bring data up to date.
282    ///
283    /// Given the date of the last weekly import and any already-applied patches,
284    /// returns the list of dates and files that still need to be applied.
285    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        // Start from day after weekly
292        let start = last_weekly_date.succ_opt().unwrap_or(last_weekly_date);
293
294        // Get all daily files from start to today
295        let all_files = Self::daily_licenses_for_range(service, start, today)?;
296
297        // Filter out already-applied patches
298        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/// Information about a supported service.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ServiceInfo {
311    /// Full service name (e.g., "amat").
312    pub name: String,
313    /// Daily file abbreviation (e.g., "am").
314    pub daily_abbrev: String,
315    /// Human-readable description.
316    pub description: String,
317    /// Associated radio service codes.
318    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        // Radio service codes should map to full service names
378        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        // CLI passes radio service codes like "HA" - this must work
386        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        // Monday Jan 12 to Friday Jan 16, 2026
407        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        // Sunday Jan 11 to Monday Jan 12
420        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        // Suppose we imported weekly on Sunday Jan 11, and today is Thursday Jan 15
433        let weekly = NaiveDate::from_ymd_opt(2026, 1, 11).unwrap();
434        let today = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
435
436        // No patches applied yet
437        let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
438
439        // Should need Mon, Tue, Wed, Thu
440        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        // Mon and Tue already applied (Jan 12, Jan 13)
451        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        // Should only need Wed, Thu
460        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(); // Next Sunday
469
470        let missing = ServiceCatalog::get_missing_daily_files("amat", weekly, &[], today).unwrap();
471
472        // All 7 days (Mon Jan 12 through Sun Jan 18) should be present
473        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        // Full week: Sun Jan 11 through Sat Jan 17
495        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        // Radio service code resolves to the full name as well.
524        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        // An unknown service still produces a (placeholder) daily filename.
548        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    // 2026-01-11 is a Sunday; each subsequent date advances one weekday.
615    #[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        // Weekly imported today, nothing newer to apply.
637        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        // Weekly on Sun Jan 11, all of week 1 (Mon-Sun) applied, now it's Thu Jan 22
646        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(), // Mon
651            NaiveDate::from_ymd_opt(2026, 1, 13).unwrap(), // Tue
652            NaiveDate::from_ymd_opt(2026, 1, 14).unwrap(), // Wed
653            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), // Thu
654            NaiveDate::from_ymd_opt(2026, 1, 16).unwrap(), // Fri
655            NaiveDate::from_ymd_opt(2026, 1, 17).unwrap(), // Sat
656            NaiveDate::from_ymd_opt(2026, 1, 18).unwrap(), // Sun
657        ];
658
659        let missing =
660            ServiceCatalog::get_missing_daily_files("amat", weekly, &applied, today).unwrap();
661
662        // Should need Mon Jan 19 through Thu Jan 22 (4 days)
663        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}