1use std::path::Path;
14
15use crate::diagnostics::{Diagnostic, DiagnosticCode};
16use crate::error::{Error, Result};
17use crate::model::calendar::{days_from_civil, days_in_month};
18use crate::model::{LeapSecond, LeapTable};
19use crate::source::lexer::tokenize;
20use crate::source::names;
21use crate::source::records::Line;
22
23pub fn parse_leap_source(bytes: &[u8], file: &Path) -> Result<LeapTable> {
27 let lines = tokenize(bytes, file)?;
28 let mut table = LeapTable::default();
29 for line in &lines {
30 match leap_keyword(line.keyword().unwrap_or("")) {
31 Some(LeapKind::Leap) => {
32 let entry = parse_leap_line(line, file)?;
33 let pos = table
35 .entries
36 .iter()
37 .position(|e| entry.trans <= e.trans)
38 .unwrap_or(table.entries.len());
39 table.entries.insert(pos, entry);
40 }
41 Some(LeapKind::Expires) => {
42 if table.expires.is_some() {
43 return Err(err(
44 DiagnosticCode::InvalidValue,
45 "multiple Expires lines",
46 file,
47 line,
48 ));
49 }
50 table.expires = Some(parse_expires_line(line, file)?);
51 }
52 None => {
53 return Err(err(
54 DiagnosticCode::UnsupportedDirective,
55 format!(
56 "input line of unknown type {:?} (a leap-source file accepts only \
57 Leap/Expires)",
58 line.keyword().unwrap_or("")
59 ),
60 file,
61 line,
62 ));
63 }
64 }
65 }
66 crate::limits::ResourceLimits::default().check_leap_count(table.entries.len())?;
69 Ok(table)
70}
71
72#[derive(Clone, Copy)]
73enum LeapKind {
74 Leap,
75 Expires,
76}
77
78fn leap_keyword(word: &str) -> Option<LeapKind> {
82 if word.is_empty() {
83 return None;
84 }
85 let lower = word.to_ascii_lowercase();
86 let leap = "leap".starts_with(&lower);
87 let expires = "expires".starts_with(&lower);
88 match (leap, expires) {
89 (true, false) => Some(LeapKind::Leap),
90 (false, true) => Some(LeapKind::Expires),
91 _ => None,
92 }
93}
94
95fn parse_leap_line(line: &Line, file: &Path) -> Result<LeapSecond> {
97 let f = &line.fields;
98 if f.len() != 7 {
99 return Err(err(
100 DiagnosticCode::InvalidFieldCount,
101 format!("Leap line needs 7 fields, found {}", f.len()),
102 file,
103 line,
104 ));
105 }
106 let trans = leap_datetime(&f[1].text, &f[2].text, &f[3].text, &f[4].text, file, line)?;
107 let correction = match f[5].text.as_str() {
108 "+" => 1,
109 "-" | "" => -1,
112 other => {
113 return Err(err(
114 DiagnosticCode::InvalidValue,
115 format!("invalid CORRECTION field {other:?} on Leap line (want + or -)"),
116 file,
117 line,
118 ));
119 }
120 };
121 let rolling = match roll_kind(&f[6].text) {
122 Some(r) => r,
123 None => {
124 return Err(err(
125 DiagnosticCode::InvalidValue,
126 format!(
127 "invalid Rolling/Stationary field {:?} on Leap line",
128 f[6].text
129 ),
130 file,
131 line,
132 ));
133 }
134 };
135 Ok(LeapSecond {
136 trans,
137 correction,
138 rolling,
139 })
140}
141
142fn parse_expires_line(line: &Line, file: &Path) -> Result<i64> {
144 let f = &line.fields;
145 if f.len() != 5 {
146 return Err(err(
147 DiagnosticCode::InvalidFieldCount,
148 format!("Expires line needs 5 fields, found {}", f.len()),
149 file,
150 line,
151 ));
152 }
153 leap_datetime(&f[1].text, &f[2].text, &f[3].text, &f[4].text, file, line)
154}
155
156fn roll_kind(word: &str) -> Option<bool> {
159 let lower = word.to_ascii_lowercase();
160 if lower.is_empty() {
161 return None;
162 }
163 let rolling = "rolling".starts_with(&lower);
164 let stationary = "stationary".starts_with(&lower);
165 match (rolling, stationary) {
166 (true, false) => Some(true),
167 (false, true) => Some(false),
168 _ => None,
169 }
170}
171
172fn leap_datetime(
175 year_s: &str,
176 mon_s: &str,
177 day_s: &str,
178 time_s: &str,
179 file: &Path,
180 line: &Line,
181) -> Result<i64> {
182 let bad = |msg: String| err(DiagnosticCode::InvalidValue, msg, file, line);
183 let year: i32 = year_s
184 .parse()
185 .map_err(|_| bad(format!("invalid leap year {year_s:?}")))?;
186 let month = names::month(mon_s).map_err(|(_, m)| bad(m))?;
187 let day: u8 = day_s
188 .parse()
189 .ok()
190 .filter(|&d| d >= 1 && d <= days_in_month(year, month))
191 .ok_or_else(|| bad(format!("invalid day of month {day_s:?}")))?;
192 let tod = leap_seconds_of_day(time_s).map_err(bad)?;
193 let t = days_from_civil(year, month, day) * 86_400 + tod;
194 if t < 0 {
195 return Err(bad("leap second precedes Epoch".to_string()));
196 }
197 Ok(t)
198}
199
200fn leap_seconds_of_day(s: &str) -> std::result::Result<i64, String> {
205 let mut parts = s.split(':');
206 let parse = |p: Option<&str>| -> std::result::Result<i64, String> {
207 match p {
208 None => Ok(0),
209 Some(x) if !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit()) => x
210 .parse::<i64>()
211 .map_err(|_| format!("number {x:?} out of range")),
212 Some(x) => Err(format!("invalid time component {x:?} in {s:?}")),
213 }
214 };
215 let h = parse(parts.next())?;
216 let m = parse(parts.next())?;
217 let sec = parse(parts.next())?;
218 if parts.next().is_some() {
219 return Err(format!("too many ':' groups in time {s:?}"));
220 }
221 if m >= 60 || sec > 60 {
222 return Err(format!("minutes/seconds out of range in {s:?}"));
223 }
224 Ok(h * 3600 + m * 60 + sec)
225}
226
227fn err(code: DiagnosticCode, msg: impl Into<String>, file: &Path, line: &Line) -> Error {
228 Error::from(Diagnostic::error(code, msg, file, line.number))
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::source::parse_into;
235 use std::path::Path;
236
237 fn leap(src: &str) -> Result<LeapTable> {
238 parse_leap_source(src.as_bytes(), Path::new("<leap>"))
239 }
240
241 #[test]
242 fn parses_stationary_and_rolling_leaps() {
243 let t = leap("Leap 2016 Dec 31 23:59:60 + S\nLeap 2015 Jun 30 23:59:60 + R\n").unwrap();
244 assert_eq!(t.entries.len(), 2);
245 assert!(t.entries[0].trans < t.entries[1].trans);
247 assert!(!t.entries[1].rolling, "S = Stationary");
248 assert!(t.entries[0].rolling, "R = Rolling");
249 assert_eq!(t.entries[0].correction, 1);
250 assert_eq!(t.entries[1].trans, days_from_civil(2017, 1, 1) * 86_400);
252 }
253
254 #[test]
255 fn parses_expires() {
256 let t = leap("Leap 2016 Dec 31 23:59:60 + S\nExpires 2025 Jan 1 0:00:00\n").unwrap();
257 assert_eq!(t.expires, Some(days_from_civil(2025, 1, 1) * 86_400));
258 }
259
260 #[test]
261 fn rejects_rule_zone_link_in_leap_source() {
262 for kw in [
263 "Rule US 2007 max - Mar Sun>=8 2:00 1:00 D",
264 "Zone X 0 - X",
265 "Link A B",
266 ] {
267 assert!(
268 leap(&format!("{kw}\n")).is_err(),
269 "{kw} must be rejected in a leap source"
270 );
271 }
272 }
273
274 #[test]
275 fn rejects_multiple_expires() {
276 let e = leap("Expires 2025 Jan 1 0:00:00\nExpires 2026 Jan 1 0:00:00\n");
277 assert!(e.is_err());
278 }
279
280 #[test]
281 fn rejects_malformed_correction_and_roll() {
282 assert!(leap("Leap 2016 Dec 31 23:59:60 ? S\n").is_err(), "bad CORR");
283 assert!(
284 leap("Leap 2016 Dec 31 23:59:60 + Wobbly\n").is_err(),
285 "bad ROLL"
286 );
287 }
288
289 #[test]
290 fn rejects_pre_epoch_leap() {
291 assert!(leap("Leap 1969 Jun 30 23:59:60 + S\n").is_err());
295 }
296
297 #[test]
299 fn zone_source_still_rejects_leap_and_expires() {
300 let mut db = crate::model::Database::default();
301 assert!(parse_into(
302 b"Leap 2016 Dec 31 23:59:60 + S\n",
303 Path::new("<z>"),
304 &mut db
305 )
306 .is_err());
307 assert!(parse_into(b"Expires 2025 Jan 1 0:00:00\n", Path::new("<z>"), &mut db).is_err());
308 }
309}