automapper-validation 0.41.2

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! MSCONS SG10 DTM+7 (Nutzungszeitpunkt) beside DTM+60 / DTM+9: conditions
//! [130] and [133], identical in every MSCONS AHB from 3.1d to 3.2.
//!
//! Both only ever appear as `X [UB1] ∧ [495] ∧ ([130] ∨ [133])` on the
//! DTM+7 value, and each reads "if DTM+… stands beside it, the gap must be …".
//! Taken one by one, both hold wherever their partner is absent — and then the
//! alternative always holds, since a reading never has every partner: a DTM+7
//! two days from its DTM+60 passed because [133] (DTM+9 of format 102) was
//! `True` without a DTM+9 (mako-twin-64, 2026-11-02). They are therefore split
//! so that the alternative says "every partner present keeps its gap":
//!
//! - [130]: no DTM+9 of format 102 is present, and every DTM+60 and every
//!   DTM+9 of format 303 lies less than 24 hours away (23 hours across the
//!   switch to summer time, 25 across the switch to winter time). A DTM+7
//!   with no partner at all holds here.
//! - [133]: a DTM+9 of format 102 is present, its day is the DTM+7's day or
//!   one day apart, and every format-303 partner beside it keeps [130]'s gap.
//!
//! "Innerhalb desselben LIN-Segments … neben diesem Segment" is the DTM+7's
//! own SG10. With a scope that holds an SG10 only that one is read; without
//! one, every SG10 of the message must hold the alternative.

use mig_types::segment::OwnedSegment;

use super::timezone::{dst_transitions_between, parse_dtm303};
use super::{ConditionResult, EvaluationContext};

const SG10_PATH: [&str; 4] = ["SG5", "SG6", "SG9", "SG10"];

/// [130] — see the module docs.
pub fn condition_130(ctx: &EvaluationContext) -> ConditionResult {
    evaluate(ctx, |gap| gap.holds_130())
}

/// [133] — see the module docs.
pub fn condition_133(ctx: &EvaluationContext) -> ConditionResult {
    evaluate(ctx, |gap| gap.holds_133())
}

fn evaluate(ctx: &EvaluationContext, scoped: impl Fn(&ReadingDates) -> bool) -> ConditionResult {
    let in_sg10 = ctx
        .scope
        .is_some_and(|scope| scope.path.iter().any(|(id, _)| *id == "SG10"));
    if in_sg10 && ctx.navigator.is_some() {
        let dtms = ctx.scoped_find_segments_in("SG10", "DTM");
        return ConditionResult::from(scoped(&ReadingDates::of(&dtms)));
    }
    let Some(nav) = ctx.navigator() else {
        return ConditionResult::Unknown;
    };
    let count = nav.group_instance_count(&SG10_PATH);
    if count == 0 {
        return ConditionResult::Unknown;
    }
    let all_hold = (0..count).all(|i| {
        let gap = ReadingDates::of(&nav.find_segments_in_group("DTM", &SG10_PATH, i));
        gap.holds_130() || gap.holds_133()
    });
    ConditionResult::from(all_hold)
}

/// The dates of one SG10.
struct ReadingDates {
    /// DTM+7, format 303.
    dtm7: Option<String>,
    /// DTM+60 and DTM+9 of format 303.
    partners_303: Vec<String>,
    /// DTM+9 of format 102.
    dtm9_102: Option<String>,
}

impl ReadingDates {
    fn of(dtms: &[OwnedSegment]) -> Self {
        let mut dates = ReadingDates {
            dtm7: None,
            partners_303: Vec::new(),
            dtm9_102: None,
        };
        for dtm in dtms {
            let Some(c507) = dtm.elements.first() else {
                continue;
            };
            let part = |i: usize| c507.get(i).map(String::as_str).unwrap_or("");
            let (qualifier, value, format) = (part(0), part(1), part(2));
            if value.is_empty() {
                continue;
            }
            match (qualifier, format) {
                ("7", _) => dates.dtm7 = dates.dtm7.take().or(Some(value.to_string())),
                ("9", "102") => dates.dtm9_102 = Some(value.to_string()),
                ("60" | "9", _) => dates.partners_303.push(value.to_string()),
                _ => {}
            }
        }
        dates
    }

    /// Every format-303 partner lies within the hour bound of the DTM+7. A
    /// value that is no format-303 date is the format check's business.
    fn partners_303_hold(&self) -> bool {
        let Some(dtm7) = self.dtm7.as_deref().and_then(parse_dtm303) else {
            return true;
        };
        self.partners_303.iter().all(|partner| {
            let Some(partner) = parse_dtm303(partner) else {
                return true;
            };
            let bound = match dst_transitions_between(dtm7, partner).signum() {
                // Net summer→winter crossing: the day has 25 hours.
                -1 => 25 * 60,
                // Net winter→summer crossing: the day has 23 hours.
                1 => 23 * 60,
                _ => 24 * 60,
            };
            (dtm7 - partner).abs() < bound
        })
    }

    fn holds_130(&self) -> bool {
        self.dtm9_102.is_none() && self.partners_303_hold()
    }

    fn holds_133(&self) -> bool {
        let (Some(dtm7), Some(dtm9)) = (self.dtm7.as_deref(), self.dtm9_102.as_deref()) else {
            return false;
        };
        let days_apart = match (day_number(dtm7), day_number(dtm9)) {
            (Some(a), Some(b)) => (a - b).abs(),
            // Not a date: the format check reports it.
            _ => 0,
        };
        days_apart <= 1 && self.partners_303_hold()
    }
}

/// The day number of the `CCYYMMDD` a value starts with.
fn day_number(value: &str) -> Option<i64> {
    let date = value.get(..8)?;
    if !date.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    let (y, m, d): (i64, i64, i64) = (
        date[..4].parse().ok()?,
        date[4..6].parse().ok()?,
        date[6..8].parse().ok()?,
    );
    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
        return None;
    }
    let a = (14 - m) / 12;
    let (y, m) = (y + 4800 - a, m + 12 * a - 3);
    Some(d + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dtm(qualifier: &str, value: &str, format: &str) -> OwnedSegment {
        OwnedSegment {
            id: "DTM".to_string(),
            elements: vec![vec![qualifier.into(), value.into(), format.into()]],
            segment_number: 0,
        }
    }

    fn dates(dtms: &[OwnedSegment]) -> (bool, bool) {
        let gap = ReadingDates::of(dtms);
        (gap.holds_130(), gap.holds_133())
    }

    #[test]
    fn a_lone_reading_holds_130() {
        assert_eq!(dates(&[dtm("7", "202411142300", "303")]), (true, false));
    }

    #[test]
    fn a_dtm60_two_days_off_holds_neither() {
        let dtms = [
            dtm("7", "202609302200", "303"),
            dtm("60", "202609282200", "303"),
        ];
        assert_eq!(dates(&dtms), (false, false));
    }

    #[test]
    fn a_dtm9_of_format_102_is_left_to_133() {
        let next_day = [dtm("9", "20241115", "102"), dtm("7", "202411142300", "303")];
        assert_eq!(dates(&next_day), (false, true));
        let three_days = [dtm("9", "20241111", "102"), dtm("7", "202411142300", "303")];
        assert_eq!(dates(&three_days), (false, false));
    }

    #[test]
    fn the_bound_follows_the_dst_switch() {
        // 2024-10-27: the day has 25 hours, 24.5 h hold.
        let autumn = [
            dtm("7", "202410272300", "303"),
            dtm("60", "202410262230", "303"),
        ];
        assert!(dates(&autumn).0);
        // 2024-03-31: the day has 23 hours, 23.5 h do not.
        let spring = [
            dtm("7", "202403312200", "303"),
            dtm("60", "202403302230", "303"),
        ];
        assert!(!dates(&spring).0);
    }
}