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}