Skip to main content

dpp_calc/kernel/
assessability.rs

1//! Why a metric has a value, or why it does not.
2
3use chrono::NaiveDate;
4
5/// The outcome of asking "which ruleset governs this product?".
6///
7/// Deliberately not `Option`. `None` collapses four legally distinct answers
8/// into one, and the difference between them is what an operator is actually
9/// owed: *"this regulation does not cover your product"*, *"it will cover it
10/// from a date we can name"*, *"we are waiting on an act that has not been
11/// adopted"* and *"the rule that covered it has been replaced"* carry different
12/// obligations and different next actions. Reporting any of them as
13/// non-compliance — or as a silent blank — misstates the operator's position.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Assessability<T> {
16    /// A governing ruleset was found.
17    Assessed(T),
18    /// A ruleset covers this product, from a known date that has not arrived.
19    NotYetInForce {
20        ruleset_id: &'static str,
21        applies_from: NaiveDate,
22    },
23    /// A ruleset exists but the instrument that would date it has not entered
24    /// into force, so it has no application date at all. Distinct from
25    /// [`NotYetInForce`](Self::NotYetInForce): there is no date to wait for yet.
26    Undetermined {
27        ruleset_id: &'static str,
28        empowerment: &'static str,
29    },
30    /// The ruleset that covered this product has ended.
31    Expired {
32        ruleset_id: &'static str,
33        until: NaiveDate,
34        /// Successor ruleset id from `regulatory_basis().superseded_by`, so a
35        /// caller can follow the regulatory chain instead of dead-ending.
36        superseded_by: Option<&'static str>,
37    },
38    /// No ruleset covers this product category at all.
39    OutOfScope,
40}
41
42impl<T> Assessability<T> {
43    /// The governing ruleset, discarding the reason when there isn't one.
44    ///
45    /// Use only where the caller genuinely has nothing to say about *why* —
46    /// anything user-facing should match on the variant instead.
47    #[must_use]
48    pub fn assessed(self) -> Option<T> {
49        match self {
50            Self::Assessed(v) => Some(v),
51            _ => None,
52        }
53    }
54
55    /// Whether a governing ruleset was found.
56    #[must_use]
57    pub const fn is_assessed(&self) -> bool {
58        matches!(self, Self::Assessed(_))
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    fn day(y: i32, m: u32, d: u32) -> NaiveDate {
67        NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
68    }
69
70    #[test]
71    fn the_four_non_assessed_outcomes_are_distinguishable() {
72        let outcomes: [Assessability<&str>; 4] = [
73            Assessability::NotYetInForce {
74                ruleset_id: "a",
75                applies_from: day(2031, 8, 18),
76            },
77            Assessability::Undetermined {
78                ruleset_id: "b",
79                empowerment: "Art. 10(5)",
80            },
81            Assessability::Expired {
82                ruleset_id: "c",
83                until: day(2026, 12, 31),
84                superseded_by: Some("c-v2"),
85            },
86            Assessability::OutOfScope,
87        ];
88        for (i, a) in outcomes.iter().enumerate() {
89            assert!(!a.is_assessed());
90            for (j, b) in outcomes.iter().enumerate() {
91                if i != j {
92                    assert_ne!(a, b, "outcomes {i} and {j} must not compare equal");
93                }
94            }
95        }
96    }
97
98    #[test]
99    fn assessed_unwraps_only_the_assessed_variant() {
100        assert_eq!(Assessability::Assessed("x").assessed(), Some("x"));
101        assert_eq!(Assessability::<&str>::OutOfScope.assessed(), None);
102        assert!(Assessability::Assessed("x").is_assessed());
103    }
104}