made_core/value_objects/ceremony/
reconsideration_conditions.rs1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::DomainError;
6
7#[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}