Skip to main content

readstat/
formats.rs

1//! SAS format string classification using regex-based detection.
2//!
3//! SAS variables carry format strings (e.g. `DATE9`, `DATETIME22.3`, `TIME8`) that
4//! determine how raw numeric values should be interpreted. This module classifies
5//! those format strings into [`ReadStatVarFormatClass`] variants (Date, `DateTime`,
6//! Time, and their sub-second precision variants), enabling correct Arrow type mapping.
7//!
8//! Supports all 118+ SAS date/time/datetime formats including ISO 8601 variants,
9//! national language (`NL*`) formats, and precision-based datetime/time formats.
10
11use std::sync::LazyLock;
12
13use regex::Regex;
14
15use crate::rs_var::ReadStatVarFormatClass;
16
17// DATETIME requesting nanosecond output (DATETIMEw.d where d=7-9). The SAS
18// numeric may have less source precision, depending on the datetime magnitude.
19static RE_DATETIME_WITH_NANO: LazyLock<Regex> =
20    LazyLock::new(|| Regex::new(r"(?xi)^DATETIME[0-9]{1,2}\.[7-9]$").unwrap());
21
22// DATETIME with microsecond precision (DATETIMEw.d where d=4-6)
23static RE_DATETIME_WITH_MICRO: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"(?xi)^DATETIME[0-9]{1,2}\.[4-6]$").unwrap());
25
26// DATETIME with millisecond precision (DATETIMEw.d where d=1-3)
27static RE_DATETIME_WITH_MILLI: LazyLock<Regex> =
28    LazyLock::new(|| Regex::new(r"(?xi)^DATETIME[0-9]{1,2}\.[1-3]$").unwrap());
29
30// TIME with nanosecond precision (TIMEw.d where d=7-9)
31static RE_TIME_WITH_NANO: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(r"(?xi)^TIME[0-9]{1,2}\.[7-9]$").unwrap());
33
34// TIME with microsecond precision (TIMEw.d where d=4-6)
35static RE_TIME_WITH_MICRO: LazyLock<Regex> =
36    LazyLock::new(|| Regex::new(r"(?xi)^TIME[0-9]{1,2}\.[4-6]$").unwrap());
37
38// TIME with millisecond precision (TIMEw.d where d=1-3)
39static RE_TIME_WITH_MILLI: LazyLock<Regex> =
40    LazyLock::new(|| Regex::new(r"(?xi)^TIME[0-9]{1,2}\.[1-3]$").unwrap());
41
42// All time formats - checked before datetime to catch NLDATMTM and NLDATMTZ
43// Suffix allows numeric widths and decimal places (8, 8.2).
44static RE_TIME: LazyLock<Regex> = LazyLock::new(|| {
45    Regex::new(
46        r"(?xi)
47        ^(
48            B8601LZ  |
49            B8601TM  |
50            B8601TX  |
51            B8601TZ  |
52            E8601LZ  |
53            E8601TM  |
54            E8601TX  |
55            E8601TZ  |
56            HHMM     |
57            HOUR     |
58            MMSS     |
59            NLDATMTM |
60            NLDATMTZ |
61            NLTIMAP  |
62            NLTIME   |
63            TIMEAMPM |
64            TIME     |
65            TOD
66        )[A-Z0-9]*(\.[A-Z0-9]*)?$",
67    )
68    .unwrap()
69});
70
71// All datetime formats - checked before date to catch DATEAMPM and DATETIME
72// NLDATM matches all NLDATM* variants; NLDATMTM/NLDATMTZ already caught by RE_TIME
73static RE_DATETIME: LazyLock<Regex> = LazyLock::new(|| {
74    Regex::new(
75        r"(?xi)
76        ^(
77            B8601DT  |
78            B8601DX  |
79            B8601DZ  |
80            B8601LX  |
81            DATEAMPM |
82            DATETIME |
83            E8601DT  |
84            E8601DX  |
85            E8601DZ  |
86            E8601LX  |
87            MDYAMPM  |
88            NLDATM
89        )[A-Z0-9]*(\.[A-Z0-9]*)?$",
90    )
91    .unwrap()
92});
93
94// All date formats
95static RE_DATE: LazyLock<Regex> = LazyLock::new(|| {
96    Regex::new(
97        r"(?xi)
98        ^(
99            B8601DA   |
100            B8601DN   |
101            DATE      |
102            DAY       |
103            DDMMYY    |
104            DOWNAME   |
105            DTDATE    |
106            DTMONYY   |
107            DTWKDATX  |
108            DTYEAR    |
109            DTYYQC    |
110            E8601DA   |
111            E8601DN   |
112            JULDAY    |
113            JULIAN    |
114            MMDDYY    |
115            MMYY      |
116            MONNAME   |
117            MONTH     |
118            MONYY     |
119            NENGO     |
120            NLDATE    |
121            QTRR?     |
122            WEEKDATX  |
123            WEEKDAY   |
124            YEAR      |
125            YYMMDD    |
126            YYMM      |
127            YYMON     |
128            YYQR      |
129            YYQ       |
130            YYWEEK[UVW]
131        )[A-Z0-9]*(\.[A-Z0-9]*)?$",
132    )
133    .unwrap()
134});
135
136/// Classifies a SAS format string into a [`ReadStatVarFormatClass`].
137///
138/// Returns `Some(class)` for recognized date/time/datetime formats, or `None`
139/// for numeric/character formats that don't represent temporal data.
140/// Matching is case-insensitive and handles bare names (`DATE`) and numeric
141/// widths (`DATE9`).
142pub(crate) fn match_var_format(v: &str) -> Option<ReadStatVarFormatClass> {
143    // Check order matters:
144    // 1. DATETIME precision variants (most specific, numeric width only)
145    // 2. TIME precision variants (most specific, numeric width only)
146    // 3. Time (catches NLDATMTM, NLDATMTZ before general NLDATM datetime match)
147    // 4. General datetime (catches DATEAMPM, DATETIME before DATE match)
148    // 5. Date (everything else)
149    if RE_DATETIME_WITH_NANO.is_match(v) {
150        Some(ReadStatVarFormatClass::DateTimeWithNanoseconds)
151    } else if RE_DATETIME_WITH_MICRO.is_match(v) {
152        Some(ReadStatVarFormatClass::DateTimeWithMicroseconds)
153    } else if RE_DATETIME_WITH_MILLI.is_match(v) {
154        Some(ReadStatVarFormatClass::DateTimeWithMilliseconds)
155    } else if RE_TIME_WITH_NANO.is_match(v) {
156        Some(ReadStatVarFormatClass::TimeWithNanoseconds)
157    } else if RE_TIME_WITH_MICRO.is_match(v) {
158        Some(ReadStatVarFormatClass::TimeWithMicroseconds)
159    } else if RE_TIME_WITH_MILLI.is_match(v) {
160        Some(ReadStatVarFormatClass::TimeWithMilliseconds)
161    } else if RE_TIME.is_match(v) {
162        Some(ReadStatVarFormatClass::Time)
163    } else if RE_DATETIME.is_match(v) {
164        Some(ReadStatVarFormatClass::DateTime)
165    } else if RE_DATE.is_match(v) {
166        Some(ReadStatVarFormatClass::Date)
167    } else {
168        None
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    // --- Date formats ---
177
178    #[test]
179    fn date_formats_with_numeric_width() {
180        // Existing formats that were already supported
181        assert_eq!(
182            match_var_format("DATE9"),
183            Some(ReadStatVarFormatClass::Date)
184        );
185        assert_eq!(
186            match_var_format("DDMMYY10"),
187            Some(ReadStatVarFormatClass::Date)
188        );
189        assert_eq!(
190            match_var_format("DDMMYYB10"),
191            Some(ReadStatVarFormatClass::Date)
192        );
193        assert_eq!(
194            match_var_format("MMDDYY10"),
195            Some(ReadStatVarFormatClass::Date)
196        );
197        assert_eq!(
198            match_var_format("YYMMDD10"),
199            Some(ReadStatVarFormatClass::Date)
200        );
201    }
202
203    #[test]
204    fn recognized_date_format_names() {
205        let date_formats = [
206            "B8601DA",
207            "B8601DN",
208            "DATE",
209            "DAY",
210            "DDMMYY",
211            "DDMMYYD",
212            "DOWNAME",
213            "DTDATE",
214            "DTMONYY",
215            "DTWKDATX",
216            "DTYEAR",
217            "DTYYQC",
218            "E8601DA",
219            "E8601DN",
220            "JULDAY",
221            "JULIAN",
222            "MMDDYY",
223            "MMDDYYD",
224            "MMYY",
225            "MMYYD",
226            "MONNAME",
227            "MONTH",
228            "MONYY",
229            "NENGO",
230            "NLDATE",
231            "NLDATECP",
232            "NLDATEL",
233            "NLDATEM",
234            "NLDATEMD",
235            "NLDATEMDL",
236            "NLDATEMDM",
237            "NLDATEMDS",
238            "NLDATEMN",
239            "NLDATES",
240            "NLDATEW",
241            "NLDATEWN",
242            "NLDATEYM",
243            "NLDATEYML",
244            "NLDATEYMM",
245            "NLDATEYMS",
246            "NLDATEYQ",
247            "NLDATEYQL",
248            "NLDATEYQM",
249            "NLDATEYQS",
250            "NLDATEYR",
251            "NLDATEYW",
252            "QTR",
253            "QTRR",
254            "WEEKDATX",
255            "WEEKDAY",
256            "YEAR",
257            "YYMM",
258            "YYMMDD",
259            "YYMMDDD",
260            "YYMMD",
261            "YYMON",
262            "YYQ",
263            "YYQD",
264            "YYQR",
265            "YYQRD",
266            "YYWEEKU",
267            "YYWEEKV",
268            "YYWEEKW",
269        ];
270        for fmt in &date_formats {
271            assert_eq!(
272                match_var_format(fmt),
273                Some(ReadStatVarFormatClass::Date),
274                "Expected Date for format: {fmt}"
275            );
276        }
277    }
278
279    // --- Time formats ---
280
281    #[test]
282    fn time_format_bare() {
283        assert_eq!(match_var_format("TIME"), Some(ReadStatVarFormatClass::Time));
284        assert_eq!(
285            match_var_format("TIME8"),
286            Some(ReadStatVarFormatClass::Time)
287        );
288    }
289
290    #[test]
291    fn recognized_time_format_names() {
292        let time_formats = [
293            "B8601LZ", "B8601TM", "B8601TX", "B8601TZ", "E8601LZ", "E8601TM", "E8601TX", "E8601TZ",
294            "HHMM", "HOUR", "MMSS", "NLDATMTM", "NLDATMTZ", "NLTIMAP", "NLTIME", "TIME",
295            "TIMEAMPM", "TOD",
296        ];
297        for fmt in &time_formats {
298            assert_eq!(
299                match_var_format(fmt),
300                Some(ReadStatVarFormatClass::Time),
301                "Expected Time for format: {fmt}"
302            );
303        }
304    }
305
306    // --- Datetime formats ---
307
308    #[test]
309    fn datetime_format_with_numeric_width() {
310        assert_eq!(
311            match_var_format("DATETIME22"),
312            Some(ReadStatVarFormatClass::DateTime)
313        );
314    }
315
316    #[test]
317    fn time_precision_formats() {
318        // .1-3 → milliseconds
319        for fmt in ["TIME10.1", "TIME11.2", "TIME12.3"] {
320            assert_eq!(
321                match_var_format(fmt),
322                Some(ReadStatVarFormatClass::TimeWithMilliseconds),
323                "Expected milliseconds for {fmt}"
324            );
325        }
326        // .4-6 → microseconds
327        for fmt in ["TIME13.4", "TIME14.5", "TIME15.6"] {
328            assert_eq!(
329                match_var_format(fmt),
330                Some(ReadStatVarFormatClass::TimeWithMicroseconds),
331                "Expected microseconds for {fmt}"
332            );
333        }
334        // .7-9 → nanoseconds
335        for fmt in ["TIME16.7", "TIME17.8", "TIME18.9"] {
336            assert_eq!(
337                match_var_format(fmt),
338                Some(ReadStatVarFormatClass::TimeWithNanoseconds),
339                "Expected nanoseconds for {fmt}"
340            );
341        }
342        // Without precision decimal, should be plain Time
343        assert_eq!(
344            match_var_format("TIME15"),
345            Some(ReadStatVarFormatClass::Time)
346        );
347    }
348
349    #[test]
350    fn datetime_precision_formats() {
351        assert_eq!(
352            match_var_format("DATETIME22.3"),
353            Some(ReadStatVarFormatClass::DateTimeWithMilliseconds)
354        );
355        assert_eq!(
356            match_var_format("DATETIME25.6"),
357            Some(ReadStatVarFormatClass::DateTimeWithMicroseconds)
358        );
359        assert_eq!(
360            match_var_format("DATETIME28.9"),
361            Some(ReadStatVarFormatClass::DateTimeWithNanoseconds)
362        );
363    }
364
365    #[test]
366    fn recognized_datetime_format_names() {
367        let datetime_formats = [
368            "B8601DT",
369            "B8601DX",
370            "B8601DZ",
371            "B8601LX",
372            "DATEAMPM",
373            "DATETIME",
374            "E8601DT",
375            "E8601DX",
376            "E8601DZ",
377            "E8601LX",
378            "MDYAMPM",
379            "NLDATM",
380            "NLDATMAP",
381            "NLDATMCP",
382            "NLDATMDT",
383            "NLDATML",
384            "NLDATMM",
385            "NLDATMMD",
386            "NLDATMMDL",
387            "NLDATMMDM",
388            "NLDATMMDS",
389            "NLDATMMN",
390            "NLDATMS",
391            "NLDATMW",
392            "NLDATMWN",
393            "NLDATMWZ",
394            "NLDATMYM",
395            "NLDATMYML",
396            "NLDATMYMM",
397            "NLDATMYMS",
398            "NLDATMYQ",
399            "NLDATMYQL",
400            "NLDATMYQM",
401            "NLDATMYQS",
402            "NLDATMYR",
403            "NLDATMYW",
404            "NLDATMZ",
405        ];
406        for fmt in &datetime_formats {
407            assert_eq!(
408                match_var_format(fmt),
409                Some(ReadStatVarFormatClass::DateTime),
410                "Expected DateTime for format: {fmt}"
411            );
412        }
413    }
414
415    // --- Non-matching formats ---
416
417    #[test]
418    fn non_date_time_formats() {
419        assert_eq!(match_var_format("BEST12"), None);
420        assert_eq!(match_var_format("$30"), None);
421        assert_eq!(match_var_format("$10"), None);
422        assert_eq!(match_var_format("COMMA12"), None);
423        assert_eq!(match_var_format(""), None);
424    }
425
426    // --- Case insensitivity ---
427
428    #[test]
429    fn case_insensitive() {
430        assert_eq!(
431            match_var_format("date9"),
432            Some(ReadStatVarFormatClass::Date)
433        );
434        assert_eq!(
435            match_var_format("datetime22"),
436            Some(ReadStatVarFormatClass::DateTime)
437        );
438        assert_eq!(
439            match_var_format("time8"),
440            Some(ReadStatVarFormatClass::Time)
441        );
442    }
443
444    // --- Property-based tests ---
445
446    mod property_tests {
447        use super::*;
448        use proptest::prelude::*;
449        use proptest::sample;
450
451        proptest! {
452            /// Arbitrary strings never cause a panic.
453            #[test]
454            fn arbitrary_strings_never_panic(s in "\\PC*") {
455                let _ = match_var_format(&s);
456            }
457
458            /// Result is deterministic: same input always produces the same output.
459            #[test]
460            fn result_is_deterministic(s in "\\PC*") {
461                let a = match_var_format(&s);
462                let b = match_var_format(&s);
463                prop_assert_eq!(a, b);
464            }
465
466            /// Case insensitivity: format classification is the same regardless of case.
467            #[test]
468            fn case_insensitive_matching(s in "[A-Za-z0-9.]{1,20}") {
469                let upper = match_var_format(&s.to_uppercase());
470                let lower = match_var_format(&s.to_lowercase());
471                prop_assert_eq!(upper, lower, "case mismatch for '{}'", s);
472            }
473
474            /// Known date prefix + numeric width always classifies as Date.
475            #[test]
476            fn date_prefix_always_matches(
477                prefix in sample::select(vec![
478                    "DATE", "DDMMYY", "MMDDYY", "YYMMDD", "JULIAN", "MONYY",
479                    "YEAR", "MONTH", "DAY", "WEEKDAY", "JULDAY",
480                ]),
481                width in 1u32..30
482            ) {
483                let fmt = format!("{prefix}{width}");
484                prop_assert_eq!(
485                    match_var_format(&fmt),
486                    Some(ReadStatVarFormatClass::Date),
487                    "Expected Date for '{}'", fmt
488                );
489            }
490
491            /// Known time prefix + numeric width always classifies as Time.
492            #[test]
493            fn time_prefix_always_matches(
494                prefix in sample::select(vec![
495                    "TIME", "HHMM", "HOUR", "MMSS", "TOD", "TIMEAMPM",
496                ]),
497                width in 1u32..30
498            ) {
499                let fmt = format!("{prefix}{width}");
500                prop_assert_eq!(
501                    match_var_format(&fmt),
502                    Some(ReadStatVarFormatClass::Time),
503                    "Expected Time for '{}'", fmt
504                );
505            }
506
507            /// DATETIME + width (no decimal) always classifies as DateTime.
508            #[test]
509            fn datetime_prefix_always_matches(width in 1u32..30) {
510                let fmt = format!("DATETIME{width}");
511                prop_assert_eq!(
512                    match_var_format(&fmt),
513                    Some(ReadStatVarFormatClass::DateTime),
514                    "Expected DateTime for '{}'", fmt
515                );
516            }
517
518            /// DATETIME with precision 1-3 → milliseconds, 4-6 → microseconds, 7-9 → nanoseconds.
519            #[test]
520            fn datetime_precision_classifies_correctly(
521                width in 1u32..30,
522                precision in 1u32..=9
523            ) {
524                let fmt = format!("DATETIME{width}.{precision}");
525                let expected = match precision {
526                    1..=3 => ReadStatVarFormatClass::DateTimeWithMilliseconds,
527                    4..=6 => ReadStatVarFormatClass::DateTimeWithMicroseconds,
528                    7..=9 => ReadStatVarFormatClass::DateTimeWithNanoseconds,
529                    _ => unreachable!(),
530                };
531                prop_assert_eq!(
532                    match_var_format(&fmt),
533                    Some(expected),
534                    "Wrong class for '{}'", fmt
535                );
536            }
537
538            /// TIME with precision 1-3 → milliseconds, 4-6 → microseconds, 7-9 → nanoseconds.
539            #[test]
540            fn time_precision_classifies_correctly(
541                width in 1u32..30,
542                precision in 1u32..=9
543            ) {
544                let fmt = format!("TIME{width}.{precision}");
545                let expected = match precision {
546                    1..=3 => ReadStatVarFormatClass::TimeWithMilliseconds,
547                    4..=6 => ReadStatVarFormatClass::TimeWithMicroseconds,
548                    7..=9 => ReadStatVarFormatClass::TimeWithNanoseconds,
549                    _ => unreachable!(),
550                };
551                prop_assert_eq!(
552                    match_var_format(&fmt),
553                    Some(expected),
554                    "Wrong class for '{}'", fmt
555                );
556            }
557
558            /// Numeric-only formats (BEST, COMMA, etc.) never match as temporal.
559            #[test]
560            fn numeric_formats_return_none(
561                prefix in sample::select(vec!["BEST", "COMMA", "DOLLAR", "PERCENT", "F", "E"]),
562                width in 1u32..30
563            ) {
564                let fmt = format!("{prefix}{width}");
565                prop_assert_eq!(
566                    match_var_format(&fmt),
567                    None,
568                    "Expected None for '{}'", fmt
569                );
570            }
571        }
572    }
573}