Skip to main content

made_core/value_objects/ceremony/
reconsideration_conditions.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::DomainError;
6
7/// Distinct reasons to ask again. Constructors validate new commands; transparent
8/// deserialization retains previously accepted journal payloads without rewriting them.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(transparent)]
11pub struct ReconsiderationConditions(Vec<String>);
12
13impl ReconsiderationConditions {
14    pub const MAX_ITEMS: usize = 100;
15    const MAX_CONDITION_LEN: usize = 1_024;
16    const FIELD: &'static str = "ceremony_guard_deferral.reconsider_when";
17
18    pub fn new(conditions: Vec<String>) -> Result<Self, DomainError> {
19        if conditions.is_empty() {
20            return Err(DomainError::EmptyCollection { field: Self::FIELD });
21        }
22        if conditions.len() > Self::MAX_ITEMS {
23            return Err(DomainError::OutOfRange {
24                field: Self::FIELD,
25                value: conditions.len() as f64,
26                min: 1.0,
27                max: Self::MAX_ITEMS as f64,
28            });
29        }
30        let mut seen = BTreeSet::new();
31        let mut validated = Vec::with_capacity(conditions.len());
32        for condition in conditions {
33            let trimmed = condition.trim();
34            if trimmed.is_empty() {
35                return Err(DomainError::EmptyField { field: Self::FIELD });
36            }
37            if trimmed.len() > Self::MAX_CONDITION_LEN {
38                return Err(DomainError::FieldTooLong {
39                    field: Self::FIELD,
40                    actual: trimmed.len(),
41                    max: Self::MAX_CONDITION_LEN,
42                });
43            }
44            if trimmed
45                .chars()
46                .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
47            {
48                return Err(DomainError::InvalidCharacters { field: Self::FIELD });
49            }
50            if !seen.insert(trimmed.to_owned()) {
51                return Err(DomainError::InvalidDocument {
52                    reason: format!("collection `{}` contains duplicate conditions", Self::FIELD),
53                });
54            }
55            validated.push(trimmed.to_owned());
56        }
57        Ok(Self(validated))
58    }
59
60    #[must_use]
61    pub fn as_slice(&self) -> &[String] {
62        &self.0
63    }
64
65    pub(crate) fn validate(&self) -> Result<(), DomainError> {
66        Self::new(self.0.clone()).map(|_| ())
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn new_conditions_are_bounded_distinct_after_trimming_and_ordered() {
76        assert!(ReconsiderationConditions::new(Vec::new()).is_err());
77        let conditions = (0..=ReconsiderationConditions::MAX_ITEMS)
78            .rev()
79            .map(|i| format!("condition-{i}"))
80            .collect::<Vec<_>>();
81        assert!(ReconsiderationConditions::new(conditions.clone()).is_err());
82        let expected = conditions[1..].to_vec();
83        assert_eq!(
84            ReconsiderationConditions::new(expected.clone())
85                .unwrap()
86                .as_slice(),
87            expected
88        );
89        for invalid in [
90            vec!["wait".into(), " wait ".into()],
91            vec![" ".into()],
92            vec!["a".repeat(1_025)],
93            vec!["bad\0condition".into()],
94        ] {
95            assert!(ReconsiderationConditions::new(invalid).is_err());
96        }
97        assert_eq!(
98            ReconsiderationConditions::new(vec!["  line one\nline two  ".into()])
99                .unwrap()
100                .as_slice(),
101            ["line one\nline two"]
102        );
103    }
104
105    #[test]
106    fn legacy_payloads_round_trip_without_rewriting_but_cannot_author_new_commands() {
107        for legacy in [
108            vec!["wait".to_owned(); 101],
109            vec![" wait ".into(), "wait".into()],
110        ] {
111            let wire = serde_json::to_value(&legacy).unwrap();
112            let restored: ReconsiderationConditions = serde_json::from_value(wire.clone()).unwrap();
113            assert_eq!(serde_json::to_value(&restored).unwrap(), wire);
114            assert!(restored.validate().is_err());
115        }
116    }
117}