1use bacnet_types::primitives::{BACnetTimeStamp, Date, Time};
7
8const 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
22fn 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
35fn 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
50pub(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
57pub(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
66fn 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
107pub 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 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
155fn 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
173pub 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
208pub 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
220pub 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}