1use crate::error::ThrustError;
2use chrono::{Datelike, Duration, NaiveDate};
3
4const AIRAC_EPOCH: (i32, u32, u32) = (1998, 1, 29);
5
6pub fn airac_code_from_date(date: NaiveDate) -> String {
7 let epoch = airac_epoch();
8 let delta_days = (date - epoch).num_days();
9 let serial = delta_days.div_euclid(28);
10 let effective = epoch + Duration::days(serial * 28);
11 let ordinal = (effective.ordinal0() / 28) + 1;
12 format!("{:02}{:02}", effective.year() % 100, ordinal)
13}
14
15pub fn airac_year_epoch(year: i32) -> Result<NaiveDate, ThrustError> {
16 let beg = NaiveDate::from_ymd_opt(year, 1, 1).ok_or_else(|| ThrustError::ParseError("Invalid year".to_string()))?;
17 let extra_days = (beg - airac_epoch()).num_days().rem_euclid(28);
18 Ok(beg - Duration::days(extra_days - 28))
19}
20
21pub fn effective_date_from_airac_code(airac_code: &str) -> Result<NaiveDate, ThrustError> {
22 let (year, cycle) = parse_airac_code(airac_code)?;
23 let year_epoch = airac_year_epoch(year)?;
24 let effective = year_epoch + Duration::days((cycle as i64 - 1) * 28);
25
26 if airac_code_from_date(effective) != airac_code {
27 return Err(ThrustError::ParseError(format!(
28 "AIRAC mismatch for calculated start date: {effective}"
29 )));
30 }
31 Ok(effective)
32}
33
34pub fn airac_interval(airac_code: &str) -> Result<(NaiveDate, NaiveDate), ThrustError> {
35 let begin = effective_date_from_airac_code(airac_code)?;
36 Ok((begin, begin + Duration::days(28)))
37}
38
39fn parse_airac_code(airac_code: &str) -> Result<(i32, u32), ThrustError> {
40 if airac_code.len() != 4 || !airac_code.chars().all(|c| c.is_ascii_digit()) {
41 return Err(ThrustError::ParseError(
42 "AIRAC code must be in YYCC format, e.g. 2508".to_string(),
43 ));
44 }
45
46 let yy = airac_code[0..2]
47 .parse::<i32>()
48 .map_err(|e| ThrustError::ParseError(e.to_string()))?;
49 let cc = airac_code[2..4]
50 .parse::<u32>()
51 .map_err(|e| ThrustError::ParseError(e.to_string()))?;
52 if !(1..=14).contains(&cc) {
53 return Err(ThrustError::ParseError(
54 "AIRAC cycle number must be between 01 and 14".to_string(),
55 ));
56 }
57
58 Ok((2000 + yy, cc))
59}
60
61fn airac_epoch() -> NaiveDate {
62 NaiveDate::from_ymd_opt(AIRAC_EPOCH.0, AIRAC_EPOCH.1, AIRAC_EPOCH.2).expect("Invalid AIRAC epoch")
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn roundtrip_code_and_date_consistency() {
71 let date = NaiveDate::from_ymd_opt(2025, 8, 15).expect("valid date");
72 let code = airac_code_from_date(date);
73 assert_eq!(code.len(), 4);
74
75 let effective = effective_date_from_airac_code(&code).expect("valid effective date");
76 assert!(effective <= date);
77 assert!(date < effective + Duration::days(28));
78
79 let (begin, end) = airac_interval(&code).expect("valid interval");
80 assert_eq!(begin, effective);
81 assert_eq!(end, begin + Duration::days(28));
82 }
83
84 #[test]
85 fn rejects_invalid_airac_codes() {
86 assert!(effective_date_from_airac_code("ABCD").is_err());
87 assert!(effective_date_from_airac_code("2515").is_err());
88 assert!(effective_date_from_airac_code("250").is_err());
89 }
90}