Skip to main content

thrust/data/
airac.rs

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}