Skip to main content

mx20022_validate/rules/
datetime.rs

1//! ISO 8601 date and datetime validation rules for ISO 20022 messages.
2//!
3//! ISO 20022 uses two date-like types:
4//! - `ISODateTime`: `YYYY-MM-DDThh:mm:ss[.sss][Z|+hh:mm|-hh:mm]`
5//! - `ISODate`:     `YYYY-MM-DD`
6//!
7//! Structural validation is performed without an external calendar library:
8//! ranges are enforced for each field, but calendar semantics (e.g. Feb 30)
9//! are intentionally not checked, as ISO 20022 schemas also don't prohibit
10//! them at the lexical level.
11
12use crate::error::{Severity, ValidationError};
13use crate::rules::Rule;
14
15// ---------------------------------------------------------------------------
16// IsoDateTimeRule
17// ---------------------------------------------------------------------------
18
19/// Validates a value as an ISO 8601 datetime string in the format used by
20/// ISO 20022: `YYYY-MM-DDThh:mm:ss[.sss][Z|+hh:mm|-hh:mm]`.
21///
22/// # Examples
23///
24/// ```
25/// use mx20022_validate::rules::datetime::IsoDateTimeRule;
26/// use mx20022_validate::rules::Rule;
27///
28/// let rule = IsoDateTimeRule;
29///
30/// let errors = rule.validate("2024-01-01T12:00:00Z", "/path");
31/// assert!(errors.is_empty(), "Valid datetime should produce no errors");
32///
33/// let errors = rule.validate("2024-13-01T00:00:00Z", "/path");
34/// assert!(!errors.is_empty(), "Month 13 should be rejected");
35///
36/// let errors = rule.validate("not-a-date", "/path");
37/// assert!(!errors.is_empty(), "Non-date string should be rejected");
38/// ```
39pub 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
61// ---------------------------------------------------------------------------
62// IsoDateRule
63// ---------------------------------------------------------------------------
64
65/// Validates a value as an ISO 8601 date string in the format used by
66/// ISO 20022: `YYYY-MM-DD`.
67///
68/// # Examples
69///
70/// ```
71/// use mx20022_validate::rules::datetime::IsoDateRule;
72/// use mx20022_validate::rules::Rule;
73///
74/// let rule = IsoDateRule;
75///
76/// let errors = rule.validate("2024-01-15", "/path");
77/// assert!(errors.is_empty(), "Valid date should produce no errors");
78///
79/// let errors = rule.validate("2024-00-15", "/path");
80/// assert!(!errors.is_empty(), "Month 00 should be rejected");
81/// ```
82pub 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
104// ---------------------------------------------------------------------------
105// Validation helpers
106// ---------------------------------------------------------------------------
107
108/// Parse a 2-digit decimal field from a byte slice at `offset` and validate
109/// that it falls in `[min, max]`.  Returns `Err` with a descriptive message
110/// on failure.
111fn 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
126/// Parse a 4-digit year and validate range [1900, 2099].
127fn 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
144/// Validate the date portion `YYYY-MM-DD` given as a byte slice at offset 0.
145/// The slice must be exactly 10 bytes.
146fn 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
168/// Validate an ISO 20022 date string `YYYY-MM-DD`.
169fn 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
180/// Validate an ISO 20022 datetime string `YYYY-MM-DDThh:mm:ss[.sss][Z|+hh:mm|-hh:mm]`.
181fn validate_iso_datetime(value: &str) -> Result<(), String> {
182    let bytes = value.as_bytes();
183
184    // Minimum: YYYY-MM-DDThh:mm:ssZ = 20 chars
185    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 portion (bytes 0..10)
193    validate_date_part(bytes, value)?;
194
195    // 'T' separator at position 10
196    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    // Time: hh:mm:ss starting at offset 11
204    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    // Optional fractional seconds and timezone at offset 19
221    let mut pos = 19;
222
223    // Optional fractional seconds: .sss (1-9 digits)
224    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    // Timezone: Z, +hh:mm, or -hh:mm
236    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            // Need at least 6 more characters: hh:mm
248            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    // No trailing garbage
273    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    // ----- IsoDateTimeRule -------------------------------------------------
288
289    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",      // month 13
304        "2024-00-01T00:00:00Z",      // month 0
305        "2024-01-32T00:00:00Z",      // day 32
306        "2024-01-00T00:00:00Z",      // day 0
307        "2024-01-01T24:00:00Z",      // hour 24
308        "2024-01-01T00:60:00Z",      // minute 60
309        "2024-01-01T00:00:60Z",      // second 60
310        "1899-12-31T00:00:00Z",      // year out of range
311        "2100-01-01T00:00:00Z",      // year out of range
312        "2024-01-01T00:00:00",       // missing timezone
313        "2024-01-01 00:00:00Z",      // space instead of T
314        "not-a-date",                // garbage
315        "",                          // empty
316        "2024-01-01T12:00:00.Z",     // trailing dot before Z
317        "2024-01-01T12:00:00+25:00", // tz hour 25
318    ];
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    // ----- IsoDateRule -----------------------------------------------------
357
358    const VALID_DATES: &[&str] = &[
359        "2024-01-01",
360        "2024-12-31",
361        "1900-01-01",
362        "2099-12-31",
363        "2024-02-29", // structural only — no calendar check
364        "2024-06-15",
365    ];
366
367    const INVALID_DATES: &[&str] = &[
368        "2024-13-01",  // month 13
369        "2024-00-01",  // month 0
370        "2024-01-32",  // day 32
371        "2024-01-00",  // day 0
372        "1899-12-31",  // year out of range
373        "2100-01-01",  // year out of range
374        "2024/01/01",  // wrong separator
375        "24-01-01",    // 2-digit year
376        "2024-1-1",    // no zero-padding
377        "not-a-date",  // garbage
378        "",            // empty
379        "2024-01-01T", // has time component
380        "20240101",    // no separators
381    ];
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}