1use crate::{common::TimeSize, validate::validate_file, DataBlock, TzifError, TzifFile, Version};
2
3#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
4pub enum InteroperabilityWarning {
5 VersionOneDataMayBeIncomplete,
6 VersionThreeOrLaterFooterMayConfuseVersionTwoReaders,
7 VersionFourLeapSecondTableMayConfuseStrictRfc8536Readers,
8 FooterMayBeIgnoredByReaders,
9 MissingEarlyNoOpTransition {
10 block: &'static str,
11 },
12 FirstTransitionAfterRecommendedCompatibilityPoint {
13 block: &'static str,
14 timestamp: i64,
15 },
16 TransitionBeforeRecommendedLowerBound {
17 block: &'static str,
18 index: usize,
19 timestamp: i64,
20 },
21 MinimumI64Transition {
22 block: &'static str,
23 index: usize,
24 },
25 NegativeTransition {
26 block: &'static str,
27 index: usize,
28 timestamp: i64,
29 },
30 FooterContainsAngleBracket,
31 DesignationNonAscii {
32 block: &'static str,
33 index: usize,
34 designation: Vec<u8>,
35 },
36 DesignationLengthOutsideRecommendedRange {
37 block: &'static str,
38 index: usize,
39 designation: String,
40 },
41 DesignationContainsNonRecommendedAscii {
42 block: &'static str,
43 index: usize,
44 designation: String,
45 },
46 UnspecifiedLocalTimeDesignation {
47 block: &'static str,
48 index: usize,
49 },
50 DaylightOffsetLessThanStandardOffset {
51 block: &'static str,
52 daylight_offset: i32,
53 standard_offset: i32,
54 },
55 LeapSecondWithSubMinuteOffset {
56 block: &'static str,
57 offset: i32,
58 },
59 OffsetOutsideConventionalRange {
60 block: &'static str,
61 index: usize,
62 offset: i32,
63 },
64 OffsetOutsideRecommendedRange {
65 block: &'static str,
66 index: usize,
67 offset: i32,
68 },
69 NegativeSubHourOffset {
70 block: &'static str,
71 index: usize,
72 offset: i32,
73 },
74 OffsetNotMultipleOfMinute {
75 block: &'static str,
76 index: usize,
77 offset: i32,
78 },
79 OffsetNotMultipleOfQuarterHour {
80 block: &'static str,
81 index: usize,
82 offset: i32,
83 },
84 UnusedLocalTimeType {
85 block: &'static str,
86 index: usize,
87 },
88 UnusedDesignationOctet {
89 block: &'static str,
90 index: usize,
91 },
92}
93
94impl TzifFile {
95 pub fn interoperability_warnings(&self) -> Result<Vec<InteroperabilityWarning>, TzifError> {
101 validate_file(self)?;
102 let mut warnings = Vec::new();
103 push_file_warnings(self, &mut warnings);
104 push_block_warnings("v1", &self.v1, TimeSize::ThirtyTwo, &mut warnings);
105 if let Some(block) = &self.v2_plus {
106 push_block_warnings("v2_plus", block, TimeSize::SixtyFour, &mut warnings);
107 }
108 warnings.sort();
109 warnings.dedup();
110 Ok(warnings)
111 }
112}
113
114pub fn designation_at(block: &DataBlock, designation_index: u8) -> Option<&[u8]> {
115 let start = usize::from(designation_index);
116 let bytes = block.designations.get(start..)?;
117 let len = bytes
118 .iter()
119 .position(|&byte| byte == 0)
120 .unwrap_or(bytes.len());
121 bytes.get(..len)
122}
123
124pub fn is_placeholder_type(block: &DataBlock, type_index: usize) -> bool {
125 let Some(local_time_type) = block.local_time_types.get(type_index) else {
126 return false;
127 };
128 designation_at(block, local_time_type.designation_index) == Some(b"-00")
129}
130
131fn push_file_warnings(file: &TzifFile, warnings: &mut Vec<InteroperabilityWarning>) {
132 if let Some(v2_plus) = &file.v2_plus {
133 if !v2_plus.transition_times.is_empty() {
134 let v1_count = file.v1.transition_times.len();
135 let representable_v2_count = v2_plus
136 .transition_times
137 .iter()
138 .filter(|&×tamp| i32::try_from(timestamp).is_ok())
139 .count();
140 if v1_count < representable_v2_count {
141 warnings.push(InteroperabilityWarning::VersionOneDataMayBeIncomplete);
142 }
143 }
144 }
145
146 if file.version >= Version::V3
147 && file
148 .footer
149 .as_deref()
150 .is_some_and(|footer| !footer.is_empty())
151 {
152 warnings
153 .push(InteroperabilityWarning::VersionThreeOrLaterFooterMayConfuseVersionTwoReaders);
154 }
155
156 if file.version == Version::V4
157 && file
158 .v2_plus
159 .as_ref()
160 .is_some_and(|block| !block.leap_seconds.is_empty())
161 {
162 warnings.push(
163 InteroperabilityWarning::VersionFourLeapSecondTableMayConfuseStrictRfc8536Readers,
164 );
165 }
166
167 if file
168 .footer
169 .as_deref()
170 .is_some_and(|footer| !footer.is_empty())
171 {
172 warnings.push(InteroperabilityWarning::FooterMayBeIgnoredByReaders);
173 if file
174 .footer
175 .as_deref()
176 .is_some_and(|footer| footer.contains(['<', '>']))
177 {
178 warnings.push(InteroperabilityWarning::FooterContainsAngleBracket);
179 }
180 }
181}
182
183fn push_block_warnings(
184 block_name: &'static str,
185 block: &DataBlock,
186 time_size: TimeSize,
187 warnings: &mut Vec<InteroperabilityWarning>,
188) {
189 if let Some((&first, &first_type)) = block
190 .transition_times
191 .first()
192 .zip(block.transition_types.first())
193 {
194 if first_type != 0 {
195 warnings
196 .push(InteroperabilityWarning::MissingEarlyNoOpTransition { block: block_name });
197 }
198 if first > i64::from(i32::MIN) {
199 warnings.push(
200 InteroperabilityWarning::FirstTransitionAfterRecommendedCompatibilityPoint {
201 block: block_name,
202 timestamp: first,
203 },
204 );
205 }
206 }
207
208 for (index, ×tamp) in block.transition_times.iter().enumerate() {
209 if timestamp < 0 {
210 warnings.push(InteroperabilityWarning::NegativeTransition {
211 block: block_name,
212 index,
213 timestamp,
214 });
215 }
216 if matches!(time_size, TimeSize::SixtyFour) && timestamp < -(1_i64 << 59) {
217 warnings.push(
218 InteroperabilityWarning::TransitionBeforeRecommendedLowerBound {
219 block: block_name,
220 index,
221 timestamp,
222 },
223 );
224 }
225 if matches!(time_size, TimeSize::SixtyFour) && timestamp == i64::MIN {
226 warnings.push(InteroperabilityWarning::MinimumI64Transition {
227 block: block_name,
228 index,
229 });
230 }
231 }
232
233 for (index, local_time_type) in block.local_time_types.iter().enumerate() {
234 push_designation_warnings(
235 block_name,
236 block,
237 index,
238 local_time_type.designation_index,
239 warnings,
240 );
241 push_offset_warnings(block_name, index, local_time_type.utc_offset, warnings);
242 }
243 push_negative_dst_warnings(block_name, block, warnings);
244 push_leap_second_offset_warnings(block_name, block, warnings);
245 push_unused_local_time_type_warnings(block_name, block, warnings);
246 push_unused_designation_octet_warnings(block_name, block, warnings);
247}
248
249fn push_designation_warnings(
250 block_name: &'static str,
251 block: &DataBlock,
252 index: usize,
253 designation_index: u8,
254 warnings: &mut Vec<InteroperabilityWarning>,
255) {
256 let Some(designation) = designation_at(block, designation_index) else {
257 return;
258 };
259 if designation == b"-00" {
260 warnings.push(InteroperabilityWarning::UnspecifiedLocalTimeDesignation {
261 block: block_name,
262 index,
263 });
264 return;
265 }
266 let Ok(designation_string) = std::str::from_utf8(designation) else {
267 warnings.push(InteroperabilityWarning::DesignationNonAscii {
268 block: block_name,
269 index,
270 designation: designation.to_vec(),
271 });
272 return;
273 };
274 if !designation_string.is_ascii() {
275 warnings.push(InteroperabilityWarning::DesignationNonAscii {
276 block: block_name,
277 index,
278 designation: designation.to_vec(),
279 });
280 return;
281 }
282 if designation_string.len() < 3 || designation_string.len() > 6 {
283 warnings.push(
284 InteroperabilityWarning::DesignationLengthOutsideRecommendedRange {
285 block: block_name,
286 index,
287 designation: designation_string.to_string(),
288 },
289 );
290 }
291 if !designation_string
292 .bytes()
293 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'+')
294 {
295 warnings.push(
296 InteroperabilityWarning::DesignationContainsNonRecommendedAscii {
297 block: block_name,
298 index,
299 designation: designation_string.to_string(),
300 },
301 );
302 }
303}
304
305fn push_offset_warnings(
306 block_name: &'static str,
307 index: usize,
308 offset: i32,
309 warnings: &mut Vec<InteroperabilityWarning>,
310) {
311 if !(-12 * 3600..=12 * 3600).contains(&offset) {
312 warnings.push(InteroperabilityWarning::OffsetOutsideConventionalRange {
313 block: block_name,
314 index,
315 offset,
316 });
317 }
318 if !(-89_999..=93_599).contains(&offset) {
319 warnings.push(InteroperabilityWarning::OffsetOutsideRecommendedRange {
320 block: block_name,
321 index,
322 offset,
323 });
324 }
325 if (-3599..=-1).contains(&offset) {
326 warnings.push(InteroperabilityWarning::NegativeSubHourOffset {
327 block: block_name,
328 index,
329 offset,
330 });
331 }
332 if offset % 60 != 0 {
333 warnings.push(InteroperabilityWarning::OffsetNotMultipleOfMinute {
334 block: block_name,
335 index,
336 offset,
337 });
338 } else if offset % (15 * 60) != 0 {
339 warnings.push(InteroperabilityWarning::OffsetNotMultipleOfQuarterHour {
340 block: block_name,
341 index,
342 offset,
343 });
344 }
345}
346
347fn push_unused_local_time_type_warnings(
348 block_name: &'static str,
349 block: &DataBlock,
350 warnings: &mut Vec<InteroperabilityWarning>,
351) {
352 let mut used = vec![false; block.local_time_types.len()];
353 if let Some(first) = used.first_mut() {
354 *first = true;
355 }
356 for &transition_type in &block.transition_types {
357 if let Some(slot) = used.get_mut(usize::from(transition_type)) {
358 *slot = true;
359 }
360 }
361 for (index, is_used) in used.into_iter().enumerate() {
362 if !is_used {
363 warnings.push(InteroperabilityWarning::UnusedLocalTimeType {
364 block: block_name,
365 index,
366 });
367 }
368 }
369}
370
371fn push_unused_designation_octet_warnings(
372 block_name: &'static str,
373 block: &DataBlock,
374 warnings: &mut Vec<InteroperabilityWarning>,
375) {
376 let mut used = vec![false; block.designations.len()];
377 for local_time_type in &block.local_time_types {
378 let start = usize::from(local_time_type.designation_index);
379 let Some(bytes) = block.designations.get(start..) else {
380 continue;
381 };
382 let Some(end) = bytes.iter().position(|&byte| byte == 0) else {
383 continue;
384 };
385 for is_used in used.iter_mut().skip(start).take(end + 1) {
386 *is_used = true;
387 }
388 }
389 for (index, is_used) in used.into_iter().enumerate() {
390 if !is_used {
391 warnings.push(InteroperabilityWarning::UnusedDesignationOctet {
392 block: block_name,
393 index,
394 });
395 }
396 }
397}
398
399fn push_negative_dst_warnings(
400 block_name: &'static str,
401 block: &DataBlock,
402 warnings: &mut Vec<InteroperabilityWarning>,
403) {
404 let standard_offsets: Vec<i32> = block
405 .local_time_types
406 .iter()
407 .filter(|local_time_type| !local_time_type.is_dst)
408 .map(|local_time_type| local_time_type.utc_offset)
409 .collect();
410 for daylight in block
411 .local_time_types
412 .iter()
413 .filter(|local_time_type| local_time_type.is_dst)
414 {
415 if let Some(&standard_offset) = standard_offsets
416 .iter()
417 .filter(|&&standard| daylight.utc_offset < standard)
418 .max()
419 {
420 warnings.push(
421 InteroperabilityWarning::DaylightOffsetLessThanStandardOffset {
422 block: block_name,
423 daylight_offset: daylight.utc_offset,
424 standard_offset,
425 },
426 );
427 }
428 }
429}
430
431fn push_leap_second_offset_warnings(
432 block_name: &'static str,
433 block: &DataBlock,
434 warnings: &mut Vec<InteroperabilityWarning>,
435) {
436 if block.leap_seconds.is_empty() {
437 return;
438 }
439 for offset in block
440 .local_time_types
441 .iter()
442 .map(|local_time_type| local_time_type.utc_offset)
443 .filter(|offset| offset % 60 != 0)
444 {
445 warnings.push(InteroperabilityWarning::LeapSecondWithSubMinuteOffset {
446 block: block_name,
447 offset,
448 });
449 }
450}