Skip to main content

aviso_validators/
time.rs

1// (C) Copyright 2024- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9//! Time validation and canonicalization handler
10//!
11//! Handles validation and canonicalization of time values used in meteorological
12//! and operational systems. Supports multiple input formats and converts them
13//! to a consistent HHMM format for topic generation and storage.
14
15use anyhow::{Context, Result, bail};
16use regex::Regex;
17
18/// Time validation handler
19///
20/// Validates and canonicalizes time values. Supports multiple input formats:
21///
22/// - **Single or double-digit hours**: 0, 1, 01, 23 (converted to HH00 format)
23/// - **HH:MM format**: Standard time with colon separator (e.g., "14:30")
24/// - **HHMM format**: Compact time without separator (e.g., "1430")
25///
26/// All valid inputs are canonicalized to HHMM format (4-digit string) for
27/// consistent representation in topics and storage systems.
28pub struct TimeHandler;
29
30impl TimeHandler {
31    /// Validate and canonicalize a time value to HHMM format
32    ///
33    /// This method handles multiple time input formats and converts them to
34    /// the standard HHMM format used throughout the system:
35    ///
36    /// # Supported Input Formats
37    /// 1. **Single or double-digit hours**: "0", "1", "01", "23" → "0000", "0100", "0100", "2300"
38    /// 2. **HH:MM format**: "14:30" → "1430", "9:15" → "0915"
39    /// 3. **HHMM format**: "1430" → "1430" (validated and normalized)
40    ///
41    /// # Arguments
42    /// * `value` - The time string to validate (in any supported format)
43    /// * `field_name` - Name of the field being validated (for error messages)
44    ///
45    /// # Returns
46    /// * `Ok(String)` - The time in canonical HHMM format
47    /// * `Err(anyhow::Error)` - Invalid time format or impossible time
48    ///
49    /// # Time Validation Rules
50    /// - Hours must be 0-23 (24-hour format)
51    /// - Minutes must be 0-59
52    /// - Leading zeros are added as needed for consistent 4-digit format
53    pub fn validate_and_canonicalize(value: &str, field_name: &str) -> Result<String> {
54        // Handle single or double-digit hours (0, 01, 1, ..., 23)
55        if let Some(canonical_time) = Self::handle_hour_only_format(value, field_name)? {
56            tracing::debug!(
57                field_name = field_name,
58                input_value = value,
59                canonical_value = %canonical_time,
60                format_type = "hour only",
61                "Time successfully validated and canonicalized"
62            );
63            return Ok(canonical_time);
64        }
65
66        // Handle HH:MM format (with colon separator)
67        if value.contains(':') {
68            return Self::handle_colon_format(value, field_name);
69        }
70
71        // Handle HHMM format (4-digit compact format)
72        if value.len() == 4 {
73            return Self::handle_compact_format(value, field_name);
74        }
75
76        // No supported format matched
77        bail!(
78            "Field '{}' has invalid time format '{}'. Expected: H, HH, HH:MM, or HHMM",
79            field_name,
80            value
81        );
82    }
83
84    /// Handle hour-only values (0, 1, 01, 23, etc.)
85    ///
86    /// This method handles single digit and double-digit hour values without
87    /// minutes, converting them to HHMM format with minutes set to 00.
88    /// It treats all numeric values of 1-2 digits as hours.
89    ///
90    /// # Arguments
91    /// * `value` - The input time value
92    /// * `field_name` - Name of the field (for error messages)
93    ///
94    /// # Returns
95    /// * `Ok(Some(String))` - Canonical time if input is a valid hour
96    /// * `Ok(None)` - Input is not a 1-2 digit numeric value
97    /// * `Err(anyhow::Error)` - Invalid hour value (out of range)
98    ///
99    /// # Examples
100    /// - "0" → "0000"
101    /// - "1" → "0100"
102    /// - "01" → "0100"
103    /// - "9" → "0900"
104    /// - "23" → "2300"
105    /// - "24" → Error (invalid hour)
106    fn handle_hour_only_format(value: &str, field_name: &str) -> Result<Option<String>> {
107        // Check if the value is 1-2 digits and all numeric
108        if !value.is_empty() && value.len() <= 2 && value.chars().all(|c| c.is_ascii_digit()) {
109            let hour: u32 = value.parse().context(format!(
110                "Invalid hour value '{}' in field '{}'",
111                value, field_name
112            ))?;
113
114            // Validate hour range (0-23)
115            if hour > 23 {
116                bail!(
117                    "Field '{}' has invalid hours: {}. Hours must be 0-23 in 24-hour format",
118                    field_name,
119                    hour
120                );
121            }
122
123            // Convert to HHMM format with minutes = 00
124            let canonical_time = format!("{:02}00", hour);
125
126            tracing::debug!(
127                field_name = field_name,
128                input_value = value,
129                canonical_value = %canonical_time,
130                parsed_hour = hour,
131                "Hour-only time successfully parsed and canonicalized"
132            );
133
134            Ok(Some(canonical_time))
135        } else {
136            // Not a 1-2 digit numeric value
137            Ok(None)
138        }
139    }
140
141    /// Handle HH:MM format with colon separator
142    ///
143    /// Parses time values in the common HH:MM format and validates
144    /// that hours and minutes are within valid ranges.
145    ///
146    /// # Arguments
147    /// * `value` - The time string in HH:MM format
148    /// * `field_name` - Name of the field (for error messages)
149    ///
150    /// # Returns
151    /// * `Ok(String)` - Canonical HHMM format
152    /// * `Err(anyhow::Error)` - Invalid format or out-of-range values
153    fn handle_colon_format(value: &str, field_name: &str) -> Result<String> {
154        let time_regex = Regex::new(r"^(\d{1,2}):(\d{2})$").unwrap();
155
156        if let Some(captures) = time_regex.captures(value) {
157            let hours: u32 = captures[1].parse().context(format!(
158                "Invalid hours '{}' in field '{}'",
159                &captures[1], field_name
160            ))?;
161            let minutes: u32 = captures[2].parse().context(format!(
162                "Invalid minutes '{}' in field '{}'",
163                &captures[2], field_name
164            ))?;
165
166            // Validate hour range (0-23)
167            if hours > 23 {
168                bail!(
169                    "Field '{}' has invalid hours: {}. Hours must be 0-23 in 24-hour format",
170                    field_name,
171                    hours
172                );
173            }
174
175            // Validate minute range (0-59)
176            if minutes > 59 {
177                bail!(
178                    "Field '{}' has invalid minutes: {}. Minutes must be 0-59",
179                    field_name,
180                    minutes
181                );
182            }
183
184            let canonical_time = format!("{:02}{:02}", hours, minutes);
185
186            tracing::debug!(
187                field_name = field_name,
188                input_value = value,
189                canonical_value = %canonical_time,
190                format_type = "HH:MM",
191                hours = hours,
192                minutes = minutes,
193                "Time successfully validated and canonicalized"
194            );
195
196            Ok(canonical_time)
197        } else {
198            bail!(
199                "Field '{}' has invalid HH:MM format '{}'. Expected format: HH:MM (e.g., 14:30, 9:05)",
200                field_name,
201                value
202            );
203        }
204    }
205
206    /// Handle HHMM compact format (4 digits)
207    ///
208    /// Validates time values in the compact 4-digit HHMM format
209    /// and ensures hours and minutes are within valid ranges.
210    ///
211    /// # Arguments
212    /// * `value` - The time string in HHMM format
213    /// * `field_name` - Name of the field (for error messages)
214    ///
215    /// # Returns
216    /// * `Ok(String)` - Validated HHMM format (same as input if valid)
217    /// * `Err(anyhow::Error)` - Invalid format or out-of-range values
218    fn handle_compact_format(value: &str, field_name: &str) -> Result<String> {
219        let time_regex = Regex::new(r"^(\d{2})(\d{2})$").unwrap();
220
221        if let Some(captures) = time_regex.captures(value) {
222            let hours: u32 = captures[1].parse().context(format!(
223                "Invalid hours '{}' in field '{}'",
224                &captures[1], field_name
225            ))?;
226            let minutes: u32 = captures[2].parse().context(format!(
227                "Invalid minutes '{}' in field '{}'",
228                &captures[2], field_name
229            ))?;
230
231            // Validate hour range (0-23)
232            if hours > 23 {
233                bail!(
234                    "Field '{}' has invalid hours: {}. Hours must be 0-23 in 24-hour format",
235                    field_name,
236                    hours
237                );
238            }
239
240            // Validate minute range (0-59)
241            if minutes > 59 {
242                bail!(
243                    "Field '{}' has invalid minutes: {}. Minutes must be 0-59",
244                    field_name,
245                    minutes
246                );
247            }
248
249            tracing::debug!(
250                field_name = field_name,
251                input_value = value,
252                canonical_value = value,
253                format_type = "HHMM",
254                hours = hours,
255                minutes = minutes,
256                "Time successfully validated and canonicalized"
257            );
258
259            Ok(value.to_string())
260        } else {
261            bail!(
262                "Field '{}' has invalid HHMM format '{}'. Expected format: HHMM with exactly 4 digits (e.g., 1430, 0905)",
263                field_name,
264                value
265            );
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    // Test single digit hours (0-9)
275    #[test]
276    fn test_single_digit_hours() {
277        assert_eq!(
278            TimeHandler::validate_and_canonicalize("0", "time").unwrap(),
279            "0000"
280        );
281        assert_eq!(
282            TimeHandler::validate_and_canonicalize("1", "time").unwrap(),
283            "0100"
284        );
285        assert_eq!(
286            TimeHandler::validate_and_canonicalize("2", "time").unwrap(),
287            "0200"
288        );
289        assert_eq!(
290            TimeHandler::validate_and_canonicalize("3", "time").unwrap(),
291            "0300"
292        );
293        assert_eq!(
294            TimeHandler::validate_and_canonicalize("4", "time").unwrap(),
295            "0400"
296        );
297        assert_eq!(
298            TimeHandler::validate_and_canonicalize("5", "time").unwrap(),
299            "0500"
300        );
301        assert_eq!(
302            TimeHandler::validate_and_canonicalize("6", "time").unwrap(),
303            "0600"
304        );
305        assert_eq!(
306            TimeHandler::validate_and_canonicalize("7", "time").unwrap(),
307            "0700"
308        );
309        assert_eq!(
310            TimeHandler::validate_and_canonicalize("8", "time").unwrap(),
311            "0800"
312        );
313        assert_eq!(
314            TimeHandler::validate_and_canonicalize("9", "time").unwrap(),
315            "0900"
316        );
317    }
318
319    // Test double digit hours with leading zeros (00-09)
320    #[test]
321    fn test_double_digit_hours_with_leading_zeros() {
322        assert_eq!(
323            TimeHandler::validate_and_canonicalize("00", "time").unwrap(),
324            "0000"
325        );
326        assert_eq!(
327            TimeHandler::validate_and_canonicalize("01", "time").unwrap(),
328            "0100"
329        );
330        assert_eq!(
331            TimeHandler::validate_and_canonicalize("02", "time").unwrap(),
332            "0200"
333        );
334        assert_eq!(
335            TimeHandler::validate_and_canonicalize("03", "time").unwrap(),
336            "0300"
337        );
338        assert_eq!(
339            TimeHandler::validate_and_canonicalize("04", "time").unwrap(),
340            "0400"
341        );
342        assert_eq!(
343            TimeHandler::validate_and_canonicalize("05", "time").unwrap(),
344            "0500"
345        );
346        assert_eq!(
347            TimeHandler::validate_and_canonicalize("06", "time").unwrap(),
348            "0600"
349        );
350        assert_eq!(
351            TimeHandler::validate_and_canonicalize("07", "time").unwrap(),
352            "0700"
353        );
354        assert_eq!(
355            TimeHandler::validate_and_canonicalize("08", "time").unwrap(),
356            "0800"
357        );
358        assert_eq!(
359            TimeHandler::validate_and_canonicalize("09", "time").unwrap(),
360            "0900"
361        );
362    }
363
364    // Test double digit hours (10-23)
365    #[test]
366    fn test_double_digit_hours() {
367        assert_eq!(
368            TimeHandler::validate_and_canonicalize("10", "time").unwrap(),
369            "1000"
370        );
371        assert_eq!(
372            TimeHandler::validate_and_canonicalize("11", "time").unwrap(),
373            "1100"
374        );
375        assert_eq!(
376            TimeHandler::validate_and_canonicalize("12", "time").unwrap(),
377            "1200"
378        );
379        assert_eq!(
380            TimeHandler::validate_and_canonicalize("13", "time").unwrap(),
381            "1300"
382        );
383        assert_eq!(
384            TimeHandler::validate_and_canonicalize("14", "time").unwrap(),
385            "1400"
386        );
387        assert_eq!(
388            TimeHandler::validate_and_canonicalize("15", "time").unwrap(),
389            "1500"
390        );
391        assert_eq!(
392            TimeHandler::validate_and_canonicalize("16", "time").unwrap(),
393            "1600"
394        );
395        assert_eq!(
396            TimeHandler::validate_and_canonicalize("17", "time").unwrap(),
397            "1700"
398        );
399        assert_eq!(
400            TimeHandler::validate_and_canonicalize("18", "time").unwrap(),
401            "1800"
402        );
403        assert_eq!(
404            TimeHandler::validate_and_canonicalize("19", "time").unwrap(),
405            "1900"
406        );
407        assert_eq!(
408            TimeHandler::validate_and_canonicalize("20", "time").unwrap(),
409            "2000"
410        );
411        assert_eq!(
412            TimeHandler::validate_and_canonicalize("21", "time").unwrap(),
413            "2100"
414        );
415        assert_eq!(
416            TimeHandler::validate_and_canonicalize("22", "time").unwrap(),
417            "2200"
418        );
419        assert_eq!(
420            TimeHandler::validate_and_canonicalize("23", "time").unwrap(),
421            "2300"
422        );
423    }
424
425    // Test invalid hours
426    #[test]
427    fn test_invalid_hours() {
428        assert!(TimeHandler::validate_and_canonicalize("24", "time").is_err());
429        assert!(TimeHandler::validate_and_canonicalize("25", "time").is_err());
430        assert!(TimeHandler::validate_and_canonicalize("99", "time").is_err());
431    }
432
433    // Test HH:MM format with single digit hours
434    #[test]
435    fn test_hh_mm_format_single_digit_hours() {
436        assert_eq!(
437            TimeHandler::validate_and_canonicalize("0:00", "time").unwrap(),
438            "0000"
439        );
440        assert_eq!(
441            TimeHandler::validate_and_canonicalize("1:15", "time").unwrap(),
442            "0115"
443        );
444        assert_eq!(
445            TimeHandler::validate_and_canonicalize("9:30", "time").unwrap(),
446            "0930"
447        );
448        assert_eq!(
449            TimeHandler::validate_and_canonicalize("9:05", "time").unwrap(),
450            "0905"
451        );
452    }
453
454    // Test HH:MM format with double digit hours
455    #[test]
456    fn test_hh_mm_format_double_digit_hours() {
457        assert_eq!(
458            TimeHandler::validate_and_canonicalize("10:00", "time").unwrap(),
459            "1000"
460        );
461        assert_eq!(
462            TimeHandler::validate_and_canonicalize("14:30", "time").unwrap(),
463            "1430"
464        );
465        assert_eq!(
466            TimeHandler::validate_and_canonicalize("23:59", "time").unwrap(),
467            "2359"
468        );
469        assert_eq!(
470            TimeHandler::validate_and_canonicalize("00:00", "time").unwrap(),
471            "0000"
472        );
473    }
474
475    // Test HH:MM format boundary cases
476    #[test]
477    fn test_hh_mm_format_boundaries() {
478        assert_eq!(
479            TimeHandler::validate_and_canonicalize("00:00", "time").unwrap(),
480            "0000"
481        );
482        assert_eq!(
483            TimeHandler::validate_and_canonicalize("23:59", "time").unwrap(),
484            "2359"
485        );
486        assert_eq!(
487            TimeHandler::validate_and_canonicalize("12:00", "time").unwrap(),
488            "1200"
489        );
490        assert_eq!(
491            TimeHandler::validate_and_canonicalize("0:59", "time").unwrap(),
492            "0059"
493        );
494    }
495
496    // Test invalid HH:MM format
497    #[test]
498    fn test_invalid_hh_mm_format() {
499        // Invalid hours
500        assert!(TimeHandler::validate_and_canonicalize("24:00", "time").is_err());
501        assert!(TimeHandler::validate_and_canonicalize("25:30", "time").is_err());
502
503        // Invalid minutes
504        assert!(TimeHandler::validate_and_canonicalize("12:60", "time").is_err());
505        assert!(TimeHandler::validate_and_canonicalize("12:99", "time").is_err());
506
507        // Invalid format
508        assert!(TimeHandler::validate_and_canonicalize("12:5", "time").is_err()); // Single digit minutes
509        assert!(TimeHandler::validate_and_canonicalize("1:2", "time").is_err()); // Single digit minutes
510    }
511
512    // Test HHMM format
513    #[test]
514    fn test_hhmm_format() {
515        assert_eq!(
516            TimeHandler::validate_and_canonicalize("0000", "time").unwrap(),
517            "0000"
518        );
519        assert_eq!(
520            TimeHandler::validate_and_canonicalize("0100", "time").unwrap(),
521            "0100"
522        );
523        assert_eq!(
524            TimeHandler::validate_and_canonicalize("0905", "time").unwrap(),
525            "0905"
526        );
527        assert_eq!(
528            TimeHandler::validate_and_canonicalize("1430", "time").unwrap(),
529            "1430"
530        );
531        assert_eq!(
532            TimeHandler::validate_and_canonicalize("2359", "time").unwrap(),
533            "2359"
534        );
535    }
536
537    // Test invalid HHMM format
538    #[test]
539    fn test_invalid_hhmm_format() {
540        // Invalid hours
541        assert!(TimeHandler::validate_and_canonicalize("2400", "time").is_err());
542        assert!(TimeHandler::validate_and_canonicalize("2500", "time").is_err());
543
544        // Invalid minutes
545        assert!(TimeHandler::validate_and_canonicalize("1260", "time").is_err());
546        assert!(TimeHandler::validate_and_canonicalize("1299", "time").is_err());
547
548        // Wrong length
549        assert!(TimeHandler::validate_and_canonicalize("123", "time").is_err()); // Too short
550        assert!(TimeHandler::validate_and_canonicalize("12345", "time").is_err()); // Too long
551    }
552
553    // Test completely invalid formats
554    #[test]
555    fn test_invalid_formats() {
556        assert!(TimeHandler::validate_and_canonicalize("", "time").is_err());
557        assert!(TimeHandler::validate_and_canonicalize("invalid", "time").is_err());
558        assert!(TimeHandler::validate_and_canonicalize("abc", "time").is_err());
559        assert!(TimeHandler::validate_and_canonicalize("12:ab", "time").is_err());
560        assert!(TimeHandler::validate_and_canonicalize("ab:30", "time").is_err());
561        assert!(TimeHandler::validate_and_canonicalize("12.30", "time").is_err()); // Wrong separator
562        assert!(TimeHandler::validate_and_canonicalize("12-30", "time").is_err()); // Wrong separator
563    }
564
565    // Test edge cases with spaces and special characters
566    #[test]
567    fn test_edge_cases() {
568        // Spaces should fail
569        assert!(TimeHandler::validate_and_canonicalize(" 12", "time").is_err());
570        assert!(TimeHandler::validate_and_canonicalize("12 ", "time").is_err());
571        assert!(TimeHandler::validate_and_canonicalize(" 12:30", "time").is_err());
572        assert!(TimeHandler::validate_and_canonicalize("12:30 ", "time").is_err());
573
574        // Mixed characters should fail
575        assert!(TimeHandler::validate_and_canonicalize("1a", "time").is_err());
576        assert!(TimeHandler::validate_and_canonicalize("a1", "time").is_err());
577    }
578
579    // Test consistency between different input formats for same time
580    #[test]
581    fn test_format_consistency() {
582        // All these should produce the same result: "0100"
583        assert_eq!(
584            TimeHandler::validate_and_canonicalize("1", "time").unwrap(),
585            "0100"
586        );
587        assert_eq!(
588            TimeHandler::validate_and_canonicalize("01", "time").unwrap(),
589            "0100"
590        );
591        assert_eq!(
592            TimeHandler::validate_and_canonicalize("1:00", "time").unwrap(),
593            "0100"
594        );
595        assert_eq!(
596            TimeHandler::validate_and_canonicalize("0100", "time").unwrap(),
597            "0100"
598        );
599
600        // All these should produce the same result: "1430"
601        assert_eq!(
602            TimeHandler::validate_and_canonicalize("14:30", "time").unwrap(),
603            "1430"
604        );
605        assert_eq!(
606            TimeHandler::validate_and_canonicalize("1430", "time").unwrap(),
607            "1430"
608        );
609
610        // All these should produce the same result: "0000"
611        assert_eq!(
612            TimeHandler::validate_and_canonicalize("0", "time").unwrap(),
613            "0000"
614        );
615        assert_eq!(
616            TimeHandler::validate_and_canonicalize("00", "time").unwrap(),
617            "0000"
618        );
619        assert_eq!(
620            TimeHandler::validate_and_canonicalize("0:00", "time").unwrap(),
621            "0000"
622        );
623        assert_eq!(
624            TimeHandler::validate_and_canonicalize("0000", "time").unwrap(),
625            "0000"
626        );
627    }
628}