1use crate::coercion::coerce_string_or_integer_to_string;
2use serde_json::Value;
3
4pub const CPU_UNIT_LABELS: &[&str] = &["256", "512", "1024", "2048", "4096", "8192", "16384", "32768"];
5
6pub const VCPU_SIZES: &[(&str, i64)] =
7 &[("0.25", 256), ("0.5", 512), ("1", 1024), ("2", 2048), ("4", 4096), ("8", 8192), ("16", 16384), ("32", 32768)];
8const MIB_PER_GIB: i64 = 1024;
9
10pub fn cpu_is_offered(value: &Value) -> Option<bool> {
14 let authored = coerce_string_or_integer_to_string(value)?;
15 Some(cpu_units(&authored).is_some())
16}
17
18pub fn task_size_is_offered(cpu: &Value, memory: &Value) -> Option<bool> {
22 let cpu_text = coerce_string_or_integer_to_string(cpu)?;
23 let memory_text = coerce_string_or_integer_to_string(memory)?;
24 Some(match (cpu_units(&cpu_text), memory_mib(&memory_text)) {
25 (Some(cpu_units), Some(memory_mib)) => valid_size_pair(cpu_units, memory_mib),
26 _ => false,
27 })
28}
29
30fn normalize_unsigned_decimal(authored: &str) -> Option<String> {
31 let unsigned = authored.strip_prefix('+').unwrap_or(authored);
32 let (whole, fraction) = match unsigned.split_once('.') {
33 Some((whole, fraction)) if !fraction.contains('.') => (whole, Some(fraction)),
34 Some(_) => return None,
35 None => (unsigned, None),
36 };
37 if whole.is_empty() && fraction.is_none_or(str::is_empty) {
38 return None;
39 }
40 if !whole.bytes().all(|byte| byte.is_ascii_digit())
41 || fraction.is_some_and(|digits| !digits.bytes().all(|byte| byte.is_ascii_digit()))
42 {
43 return None;
44 }
45
46 let significant_whole = whole.trim_start_matches('0');
47 let normalized_whole = if significant_whole.is_empty() { "0" } else { significant_whole };
48 let significant_fraction = fraction.unwrap_or_default().trim_end_matches('0');
49 if significant_fraction.is_empty() {
50 Some(normalized_whole.to_string())
51 } else {
52 Some(format!("{normalized_whole}.{significant_fraction}"))
53 }
54}
55
56fn cpu_units(authored: &str) -> Option<i64> {
57 if authored.trim() != authored {
58 return None;
59 }
60 if authored.bytes().all(|byte| byte.is_ascii_digit()) && !authored.is_empty() {
61 return CPU_UNIT_LABELS.iter().find(|offered| **offered == authored).and_then(|offered| offered.parse().ok());
62 }
63
64 let lower = authored.to_ascii_lowercase();
65 let normalized_vcpu = normalize_unsigned_decimal(lower.strip_suffix("vcpu")?.trim_end())?;
66 VCPU_SIZES.iter().find(|(label, _)| *label == normalized_vcpu).map(|(_, units)| *units)
67}
68
69fn memory_mib(authored: &str) -> Option<i64> {
70 if authored.trim() != authored {
71 return None;
72 }
73 if !authored.is_empty() && authored.bytes().all(|byte| byte.is_ascii_digit()) {
74 return authored.parse().ok();
75 }
76
77 let lower = authored.to_ascii_lowercase();
78 let normalized_gib = normalize_unsigned_decimal(lower.strip_suffix("gb")?.trim_end())?;
79 if normalized_gib == "0.5" {
80 return Some(MIB_PER_GIB / 2);
81 }
82 if normalized_gib.contains('.') {
83 return None;
84 }
85 normalized_gib.parse::<i64>().ok()?.checked_mul(MIB_PER_GIB)
86}
87
88fn valid_size_pair(cpu: i64, memory: i64) -> bool {
89 let (minimum, maximum, step) = match cpu {
90 256 => return [512, 1024, 2048].contains(&memory),
91 512 => (1024, 4096, 1024),
92 1024 => (2048, 8192, 1024),
93 2048 => (4096, 16384, 1024),
94 4096 => (8192, 30720, 1024),
95 8192 => (16384, 61440, 4096),
96 16384 => (32768, 122880, 8192),
97 32768 => return [61440, 122880, 249856].contains(&memory),
98 _ => return false,
99 };
100 (minimum..=maximum).contains(&memory) && memory % step == 0
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use serde_json::json;
107
108 #[test]
109 fn cpu_accepts_ecs_decimal_spellings_without_scientific_notation() {
110 for value in [
111 json!(256),
112 json!("256"),
113 json!(".25 vCPU"),
114 json!("0.250 vCPU"),
115 json!("0.5vCPU"),
116 json!("02 VCPU"),
117 json!("+2.00 vCpu"),
118 json!("2. vCPU"),
119 json!("2\tvCPU"),
120 json!("2\nvCPU"),
121 json!("16vcpu"),
122 json!("32 VCPU"),
123 ] {
124 assert_eq!(cpu_is_offered(&value), Some(true), "{value} must name an offered Cpu size");
125 }
126 }
127
128 #[test]
129 fn cpu_rejects_unoffered_and_unsupported_spellings_and_defers_other_shapes() {
130 for value in [
131 json!("0512"),
132 json!("3 vCPU"),
133 json!("2e0 vCPU"),
134 json!(" 2 vCPU"),
135 json!("2 vCPU "),
136 json!("-2 vCPU"),
137 json!("bananas"),
138 json!(128),
139 ] {
140 assert_eq!(cpu_is_offered(&value), Some(false), "{value} must not name an offered Cpu size");
141 }
142 for value in [json!(256.0), json!(true), json!([256]), json!({"Cpu": 256}), json!(null)] {
143 assert_eq!(cpu_is_offered(&value), None, "{value} must be left to schema type validation");
144 }
145 }
146
147 #[test]
148 fn task_sizes_accept_ecs_decimal_suffixes_without_scientific_notation() {
149 for (cpu, memory) in [
150 (json!(256), json!(512)),
151 (json!("0.25 vCPU"), json!("0.5 GB")),
152 (json!("02 vCPU"), json!("04 GB")),
153 (json!("+2.0 vCPU"), json!("+4.0 GB")),
154 (json!("2. vCPU"), json!("4. GB")),
155 (json!("8 vCPU"), json!("60GB")),
156 (json!("16 vCPU"), json!("120 GB")),
157 (json!(32768), json!("60 GB")),
158 (json!("32 vCPU"), json!("120 GB")),
159 (json!("32768"), json!("244 GB")),
160 ] {
161 assert_eq!(task_size_is_offered(&cpu, &memory), Some(true), "{cpu}/{memory} must be offered");
162 }
163 }
164
165 #[test]
166 fn task_sizes_reject_scientific_notation_outer_whitespace_and_invalid_combinations() {
167 for (cpu, memory) in [
168 (json!("2e0 vCPU"), json!("4 GB")),
169 (json!("2 vCPU"), json!("4e0 GB")),
170 (json!(" 2 vCPU"), json!("4 GB")),
171 (json!("2 vCPU"), json!(" 4 GB")),
172 (json!("2 vCPU "), json!("4 GB")),
173 (json!("2 vCPU"), json!("4 GB ")),
174 (json!("2 vCPU"), json!("4096.0")),
175 (json!(8192), json!(17408)),
176 (json!("8 vCPU"), json!("17 GB")),
177 (json!(16384), json!(36864)),
178 (json!(32768), json!(65536)),
179 (json!(32768), json!(245760)),
180 (json!("32 vCPU"), json!("240 GB")),
181 ] {
182 assert_eq!(task_size_is_offered(&cpu, &memory), Some(false), "{cpu}/{memory} must not be offered");
183 }
184 }
185
186 #[test]
187 fn task_size_conversion_rejects_overflow_without_panicking() {
188 assert_eq!(task_size_is_offered(&json!(8192), &json!("9999999999999999GB")), Some(false));
189 assert_eq!(task_size_is_offered(&json!(8192), &json!("9223372036854775807GB")), Some(false));
190 }
191
192 #[test]
193 fn task_size_defers_non_string_non_integer_shapes() {
194 assert_eq!(task_size_is_offered(&json!(256.0), &json!(512)), None);
195 assert_eq!(task_size_is_offered(&json!(256), &json!([512])), None);
196 }
197}