1use crate::error::{Severity, ValidationError};
13use crate::rules::Rule;
14
15pub struct IsoDateTimeRule;
40
41impl Rule for IsoDateTimeRule {
42 fn id(&self) -> &'static str {
43 "DATETIME_CHECK"
44 }
45
46 fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
47 match validate_iso_datetime(value) {
48 Ok(()) => vec![],
49 Err(msg) => {
50 vec![ValidationError::new(
51 path,
52 Severity::Error,
53 "DATETIME_CHECK",
54 msg,
55 )]
56 }
57 }
58 }
59}
60
61pub struct IsoDateRule;
83
84impl Rule for IsoDateRule {
85 fn id(&self) -> &'static str {
86 "DATE_CHECK"
87 }
88
89 fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
90 match validate_iso_date(value) {
91 Ok(()) => vec![],
92 Err(msg) => {
93 vec![ValidationError::new(
94 path,
95 Severity::Error,
96 "DATE_CHECK",
97 msg,
98 )]
99 }
100 }
101 }
102}
103
104fn parse_two_digits(s: &[u8], offset: usize, field: &str, min: u8, max: u8) -> Result<u8, String> {
112 let a = s[offset];
113 let b = s[offset + 1];
114 if !a.is_ascii_digit() || !b.is_ascii_digit() {
115 return Err(format!("{field} must be two decimal digits"));
116 }
117 let value = (a - b'0') * 10 + (b - b'0');
118 if value < min || value > max {
119 return Err(format!(
120 "{field} must be in range [{min}, {max}], got {value}"
121 ));
122 }
123 Ok(value)
124}
125
126fn parse_year(s: &[u8], offset: usize) -> Result<u16, String> {
128 let digits: Vec<u8> = s[offset..offset + 4].to_vec();
129 for d in &digits {
130 if !d.is_ascii_digit() {
131 return Err("Year must be four decimal digits".to_owned());
132 }
133 }
134 let year: u16 = u16::from(digits[0] - b'0') * 1000
135 + u16::from(digits[1] - b'0') * 100
136 + u16::from(digits[2] - b'0') * 10
137 + u16::from(digits[3] - b'0');
138 if !(1900..=2099).contains(&year) {
139 return Err(format!("Year must be in range [1900, 2099], got {year}"));
140 }
141 Ok(year)
142}
143
144fn validate_date_part(bytes: &[u8], original: &str) -> Result<(), String> {
147 if bytes.len() < 10 {
148 return Err(format!("Value is too short to be a date: `{original}`"));
149 }
150 parse_year(bytes, 0)?;
151 if bytes[4] != b'-' {
152 return Err(format!(
153 "Expected '-' after year in `{original}`, got `{}`",
154 bytes[4] as char
155 ));
156 }
157 parse_two_digits(bytes, 5, "Month", 1, 12)?;
158 if bytes[7] != b'-' {
159 return Err(format!(
160 "Expected '-' after month in `{original}`, got `{}`",
161 bytes[7] as char
162 ));
163 }
164 parse_two_digits(bytes, 8, "Day", 1, 31)?;
165 Ok(())
166}
167
168fn validate_iso_date(value: &str) -> Result<(), String> {
170 let bytes = value.as_bytes();
171 if bytes.len() != 10 {
172 return Err(format!(
173 "Date must be exactly 10 characters (YYYY-MM-DD), got {}: `{value}`",
174 bytes.len()
175 ));
176 }
177 validate_date_part(bytes, value)
178}
179
180fn validate_iso_datetime(value: &str) -> Result<(), String> {
182 let bytes = value.as_bytes();
183
184 if bytes.len() < 20 {
186 return Err(format!(
187 "Datetime is too short (minimum 20 characters), got {}: `{value}`",
188 bytes.len()
189 ));
190 }
191
192 validate_date_part(bytes, value)?;
194
195 if bytes[10] != b'T' {
197 return Err(format!(
198 "Expected 'T' date/time separator at position 11 in `{value}`, got `{}`",
199 bytes[10] as char
200 ));
201 }
202
203 parse_two_digits(bytes, 11, "Hour", 0, 23)?;
205 if bytes[13] != b':' {
206 return Err(format!(
207 "Expected ':' after hour in `{value}`, got `{}`",
208 bytes[13] as char
209 ));
210 }
211 parse_two_digits(bytes, 14, "Minute", 0, 59)?;
212 if bytes[16] != b':' {
213 return Err(format!(
214 "Expected ':' after minute in `{value}`, got `{}`",
215 bytes[16] as char
216 ));
217 }
218 parse_two_digits(bytes, 17, "Second", 0, 59)?;
219
220 let mut pos = 19;
222
223 if pos < bytes.len() && bytes[pos] == b'.' {
225 pos += 1;
226 let frac_start = pos;
227 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
228 pos += 1;
229 }
230 if pos == frac_start {
231 return Err(format!("Expected fractional digits after '.' in `{value}`"));
232 }
233 }
234
235 if pos >= bytes.len() {
237 return Err(format!(
238 "Missing timezone designator (Z, +hh:mm, or -hh:mm) in `{value}`"
239 ));
240 }
241
242 match bytes[pos] {
243 b'Z' => {
244 pos += 1;
245 }
246 b'+' | b'-' => {
247 if bytes.len() < pos + 6 {
249 return Err(format!("Timezone offset is truncated in `{value}`"));
250 }
251 pos += 1;
252 parse_two_digits(bytes, pos, "Timezone hour", 0, 23)?;
253 pos += 2;
254 if bytes[pos] != b':' {
255 return Err(format!(
256 "Expected ':' in timezone offset in `{value}`, got `{}`",
257 bytes[pos] as char
258 ));
259 }
260 pos += 1;
261 parse_two_digits(bytes, pos, "Timezone minute", 0, 59)?;
262 pos += 2;
263 }
264 other => {
265 return Err(format!(
266 "Invalid timezone designator `{}` in `{value}`; expected Z, +, or -",
267 other as char
268 ));
269 }
270 }
271
272 if pos != bytes.len() {
274 return Err(format!(
275 "Unexpected trailing characters in datetime `{value}`"
276 ));
277 }
278
279 Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::rules::Rule;
286
287 const VALID_DATETIMES: &[&str] = &[
290 "2024-01-01T12:00:00Z",
291 "2024-01-15T23:59:59Z",
292 "1900-01-01T00:00:00Z",
293 "2099-12-31T23:59:59Z",
294 "2024-06-15T08:30:00+05:30",
295 "2024-06-15T08:30:00-07:00",
296 "2024-01-01T12:00:00.000Z",
297 "2024-01-01T12:00:00.123Z",
298 "2024-01-01T12:00:00.123456789Z",
299 "2024-01-01T00:00:00+00:00",
300 ];
301
302 const INVALID_DATETIMES: &[&str] = &[
303 "2024-13-01T00:00:00Z", "2024-00-01T00:00:00Z", "2024-01-32T00:00:00Z", "2024-01-00T00:00:00Z", "2024-01-01T24:00:00Z", "2024-01-01T00:60:00Z", "2024-01-01T00:00:60Z", "1899-12-31T00:00:00Z", "2100-01-01T00:00:00Z", "2024-01-01T00:00:00", "2024-01-01 00:00:00Z", "not-a-date", "", "2024-01-01T12:00:00.Z", "2024-01-01T12:00:00+25:00", ];
319
320 #[test]
321 fn valid_datetimes_pass() {
322 let rule = IsoDateTimeRule;
323 for dt in VALID_DATETIMES {
324 let errors = rule.validate(dt, "/test");
325 assert!(
326 errors.is_empty(),
327 "Expected no errors for `{dt}`, got: {errors:?}"
328 );
329 }
330 }
331
332 #[test]
333 fn invalid_datetimes_fail() {
334 let rule = IsoDateTimeRule;
335 for dt in INVALID_DATETIMES {
336 let errors = rule.validate(dt, "/test");
337 assert!(!errors.is_empty(), "Expected errors for `{dt}`");
338 }
339 }
340
341 #[test]
342 fn datetime_error_has_correct_rule_id() {
343 let rule = IsoDateTimeRule;
344 let errors = rule.validate("not-a-date", "/Document/CreDtTm");
345 assert_eq!(errors.len(), 1);
346 assert_eq!(errors[0].rule_id, "DATETIME_CHECK");
347 assert_eq!(errors[0].path, "/Document/CreDtTm");
348 assert_eq!(errors[0].severity, Severity::Error);
349 }
350
351 #[test]
352 fn datetime_rule_id_is_datetime_check() {
353 assert_eq!(IsoDateTimeRule.id(), "DATETIME_CHECK");
354 }
355
356 const VALID_DATES: &[&str] = &[
359 "2024-01-01",
360 "2024-12-31",
361 "1900-01-01",
362 "2099-12-31",
363 "2024-02-29", "2024-06-15",
365 ];
366
367 const INVALID_DATES: &[&str] = &[
368 "2024-13-01", "2024-00-01", "2024-01-32", "2024-01-00", "1899-12-31", "2100-01-01", "2024/01/01", "24-01-01", "2024-1-1", "not-a-date", "", "2024-01-01T", "20240101", ];
382
383 #[test]
384 fn valid_dates_pass() {
385 let rule = IsoDateRule;
386 for d in VALID_DATES {
387 let errors = rule.validate(d, "/test");
388 assert!(
389 errors.is_empty(),
390 "Expected no errors for `{d}`, got: {errors:?}"
391 );
392 }
393 }
394
395 #[test]
396 fn invalid_dates_fail() {
397 let rule = IsoDateRule;
398 for d in INVALID_DATES {
399 let errors = rule.validate(d, "/test");
400 assert!(!errors.is_empty(), "Expected errors for `{d}`");
401 }
402 }
403
404 #[test]
405 fn date_error_has_correct_rule_id() {
406 let rule = IsoDateRule;
407 let errors = rule.validate("not-a-date", "/Document/IntrBkSttlmDt");
408 assert_eq!(errors.len(), 1);
409 assert_eq!(errors[0].rule_id, "DATE_CHECK");
410 assert_eq!(errors[0].path, "/Document/IntrBkSttlmDt");
411 assert_eq!(errors[0].severity, Severity::Error);
412 }
413
414 #[test]
415 fn date_rule_id_is_date_check() {
416 assert_eq!(IsoDateRule.id(), "DATE_CHECK");
417 }
418}