Skip to main content

bacnet_ops/parse/
datetime.rs

1//! Dates, times, date-times and timestamps in the forms the formatter
2//! prints them, so a value read can be typed back: `2026-10-19 Mon`,
3//! `*-even-months-last-day`, `07:00`, `07:00:05.25`, `2026-10-19 07:00`,
4//! `any date`, `any time`, `unspecified` and `seq 42`.
5
6use bacnet_types::primitives::{BACnetTimeStamp, Date, Time};
7
8/// Lowest and highest full year a Date holds; 255 above 1900 means
9/// unspecified.
10const YEARS: std::ops::RangeInclusive<u16> = 1900..=2154;
11
12const WEEKDAYS: [&str; 7] = [
13    "monday",
14    "tuesday",
15    "wednesday",
16    "thursday",
17    "friday",
18    "saturday",
19    "sunday",
20];
21
22/// A weekday by name, `Mon` or `monday` in any case, as its number from
23/// Monday 1.
24fn weekday(word: &str) -> Option<u8> {
25    let word = word.to_ascii_lowercase();
26    if word.len() < 3 {
27        return None;
28    }
29    WEEKDAYS
30        .iter()
31        .position(|day| day.starts_with(&word))
32        .map(|index| index as u8 + 1)
33}
34
35/// One numeric field, or `*` for unspecified.
36fn field(name: &str, text: &str, range: std::ops::RangeInclusive<u8>) -> Result<u8, String> {
37    if text == "*" {
38        return Ok(Date::UNSPECIFIED);
39    }
40    match text.parse::<u8>() {
41        Ok(value) if range.contains(&value) => Ok(value),
42        _ => Err(format!(
43            "invalid {name} '{text}': {}-{} or * for any",
44            range.start(),
45            range.end()
46        )),
47    }
48}
49
50/// Whether `text` starts like a date: four digits or `*`, then a hyphen.
51pub(super) fn looks_like_date(text: &str) -> bool {
52    let year = text.split('-').next().unwrap_or_default();
53    text.contains('-')
54        && (year == "*" || (year.len() == 4 && year.bytes().all(|b| b.is_ascii_digit())))
55}
56
57/// Whether `text` looks like a time: digits or `*` around a colon.
58pub(super) fn looks_like_time(text: &str) -> bool {
59    !text.is_empty()
60        && text.contains(':')
61        && text
62            .bytes()
63            .all(|b| b.is_ascii_digit() || b"*:.".contains(&b))
64}
65
66/// The `YYYY-MM-DD` part of a date, special months and days by name.
67fn ymd(text: &str) -> Result<Date, String> {
68    let invalid = || {
69        format!(
70            "invalid date '{text}': expected YYYY-MM-DD, * for any field, \
71             odd-months/even-months, last-day/odd-days/even-days"
72        )
73    };
74    let (year, rest) = text.split_once('-').ok_or_else(invalid)?;
75    let year = if year == "*" {
76        Date::UNSPECIFIED
77    } else {
78        let full: u16 = year.parse().map_err(|_| invalid())?;
79        if !YEARS.contains(&full) {
80            return Err(format!("invalid year {full}: 1900-2154 or * for any"));
81        }
82        (full - 1900) as u8
83    };
84    let lower = rest.to_ascii_lowercase();
85    let (month, day) = if let Some(day) = lower.strip_prefix("odd-months-") {
86        (13, day)
87    } else if let Some(day) = lower.strip_prefix("even-months-") {
88        (14, day)
89    } else {
90        let (month, day) = lower.split_once('-').ok_or_else(invalid)?;
91        (field("month", month, 1..=14)?, day)
92    };
93    let day = match day {
94        "last-day" => 32,
95        "odd-days" => 33,
96        "even-days" => 34,
97        day => field("day", day, 1..=34)?,
98    };
99    Ok(Date {
100        year,
101        month,
102        day,
103        day_of_week: Date::UNSPECIFIED,
104    })
105}
106
107/// A time: `HH:MM`, `HH:MM:SS` or `HH:MM:SS.hh`, `*` for any field, or
108/// `any time`. Fields left off are 0.
109pub fn parse_time(text: &str) -> Result<Time, String> {
110    let text = text.trim();
111    if text.eq_ignore_ascii_case("any time") {
112        return Ok(Time {
113            hour: Time::UNSPECIFIED,
114            minute: Time::UNSPECIFIED,
115            second: Time::UNSPECIFIED,
116            hundredths: Time::UNSPECIFIED,
117        });
118    }
119    let (clock, hundredths) = match text.split_once('.') {
120        Some((clock, hundredths)) => (clock, Some(hundredths)),
121        None => (text, None),
122    };
123    let parts: Vec<&str> = clock.split(':').collect();
124    let (hour, minute, second) = match parts.as_slice() {
125        [hour, minute] => (*hour, *minute, None),
126        [hour, minute, second] => (*hour, *minute, Some(*second)),
127        _ => {
128            return Err(format!(
129                "invalid time '{text}': expected HH:MM, HH:MM:SS or HH:MM:SS.hh"
130            ));
131        }
132    };
133    if hundredths.is_some() && second.is_none() {
134        return Err(format!(
135            "invalid time '{text}': hundredths need seconds, HH:MM:SS.hh"
136        ));
137    }
138    // Two digits, as the formatter prints them: `.5` could mean 5 or 50.
139    if let Some(h) = hundredths
140        && h != "*"
141        && h.len() != 2
142    {
143        return Err(format!(
144            "invalid hundredths '{h}' in '{text}': two digits, .05 or .50, or * for any"
145        ));
146    }
147    Ok(Time {
148        hour: field("hour", hour, 0..=23)?,
149        minute: field("minute", minute, 0..=59)?,
150        second: second.map_or(Ok(0), |s| field("second", s, 0..=59))?,
151        hundredths: hundredths.map_or(Ok(0), |h| field("hundredths", h, 0..=99))?,
152    })
153}
154
155/// The words of a date or date-time, with `any date` and `any time` kept
156/// as one word each.
157fn words(text: &str) -> Vec<String> {
158    let mut out: Vec<String> = Vec::new();
159    for word in text.split_whitespace() {
160        let joins = matches!(out.last(), Some(last) if last.eq_ignore_ascii_case("any"))
161            && (word.eq_ignore_ascii_case("date") || word.eq_ignore_ascii_case("time"));
162        match out.last_mut() {
163            Some(last) if joins => {
164                last.push(' ');
165                last.push_str(word);
166            }
167            _ => out.push(word.to_string()),
168        }
169    }
170    out
171}
172
173/// A date and maybe a time after it, as the formatter writes them:
174/// `2026-10-19`, `2026-10-19 Mon 07:00`, `any date 07:00`, or
175/// `unspecified` for neither. The time is `None` when none was typed.
176pub fn parse_date_time(text: &str) -> Result<(Date, Option<Time>), String> {
177    let text = text.trim();
178    let any_date = Date {
179        year: Date::UNSPECIFIED,
180        month: Date::UNSPECIFIED,
181        day: Date::UNSPECIFIED,
182        day_of_week: Date::UNSPECIFIED,
183    };
184    if text.eq_ignore_ascii_case("unspecified") {
185        return Ok((any_date, Some(parse_time("any time")?)));
186    }
187    let words = words(text);
188    let mut rest = words.iter().map(String::as_str).peekable();
189    let mut date = match rest.next() {
190        Some(word) if word.eq_ignore_ascii_case("any date") => any_date,
191        Some(word) => ymd(word)?,
192        None => return Err("expected a date, YYYY-MM-DD".into()),
193    };
194    if let Some(day) = rest.peek().and_then(|word| weekday(word)) {
195        date.day_of_week = day;
196        rest.next();
197    }
198    let time = match rest.next() {
199        None => None,
200        Some(word) => Some(parse_time(word)?),
201    };
202    if let Some(extra) = rest.next() {
203        return Err(format!("unexpected '{extra}' after the date and time"));
204    }
205    Ok((date, time))
206}
207
208/// A date with no time after it: `2026-10-19`, `2026-10-19 Mon`,
209/// `*-*-* Fri`, `any date`.
210pub fn parse_date(text: &str) -> Result<Date, String> {
211    match parse_date_time(text)? {
212        (date, None) => Ok(date),
213        (_, Some(_)) => Err(format!(
214            "'{}' has a time; a Date takes only YYYY-MM-DD and a weekday",
215            text.trim()
216        )),
217    }
218}
219
220/// A BACnetTimeStamp as the formatter prints one: `seq 42`, a time
221/// (`14:30:05`, `any time`), or a date and time (`2026-10-25 Sun 14:30:05`;
222/// a date alone leaves the time unspecified).
223pub fn parse_timestamp(text: &str) -> Result<BACnetTimeStamp, String> {
224    let text = text.trim();
225    if let Some(number) = text
226        .strip_prefix("seq ")
227        .or_else(|| text.strip_prefix("sequence "))
228    {
229        let number = number.trim();
230        return number
231            .parse::<u16>()
232            .map(BACnetTimeStamp::SequenceNumber)
233            .map_err(|_| format!("invalid sequence number '{number}': 0-65535"));
234    }
235    if looks_like_time(text) || text.eq_ignore_ascii_case("any time") {
236        return parse_time(text).map(BACnetTimeStamp::Time);
237    }
238    let (date, time) = parse_date_time(text).map_err(|e| {
239        format!("{e}; a timestamp is `seq N`, a time `HH:MM[:SS[.hh]]` or a date and time")
240    })?;
241    Ok(BACnetTimeStamp::DateTime {
242        date,
243        time: match time {
244            Some(time) => time,
245            None => parse_time("any time")?,
246        },
247    })
248}