aviso_validators/expver.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
9use anyhow::{Result, bail};
10
11/// Experiment version validation handler
12///
13/// Handles experiment version identifiers for different runs and model versions
14///
15/// - **Numeric versions**: Integers that are zero-padded to 4 digits (e.g., 1 → "0001")
16/// - **String versions**: Alphanumeric identifiers converted to lowercase (e.g., "PROD" → "prod")
17///
18/// The canonicalization ensures consistent representation for topic generation
19/// and database storage while supporting the flexibility needed by different
20/// operational workflows.
21pub struct ExpverHandler;
22
23impl ExpverHandler {
24 /// Validate and canonicalize an experiment version value
25 ///
26 /// This method handles both numeric and string experiment versions:
27 /// - Numeric values are zero-padded to 4 digits for consistency
28 /// - String values are converted to lowercase for standardization
29 /// - Empty values use the configured default if available
30 ///
31 /// # Arguments
32 /// * `value` - The experiment version to validate (can be empty if default provided)
33 /// * `default` - Optional default value to use when input is empty
34 /// * `field_name` - Name of the field being validated (for error messages)
35 ///
36 /// # Returns
37 /// * `Ok(String)` - The canonicalized experiment version
38 /// * `Err(anyhow::Error)` - Empty value with no default provided
39 pub fn validate_and_canonicalize(
40 value: &str,
41 default: Option<&str>,
42 field_name: &str,
43 ) -> Result<String> {
44 // Handle empty values by using default if available
45 if value.is_empty() {
46 if let Some(default_val) = default {
47 let canonicalized_default = Self::canonicalize_expver(default_val);
48 tracing::debug!(
49 field_name = field_name,
50 default_value = default_val,
51 canonical_value = %canonicalized_default,
52 "Using default experiment version"
53 );
54 return Ok(canonicalized_default);
55 } else {
56 bail!("Field '{}' cannot be empty", field_name);
57 }
58 }
59
60 // Canonicalize the provided value
61 let canonicalized = Self::canonicalize_expver(value);
62
63 tracing::debug!(
64 field_name = field_name,
65 input_value = value,
66 canonical_value = %canonicalized,
67 value_type = if value.parse::<u32>().is_ok() { "numeric" } else { "string" },
68 "Experiment version successfully validated and canonicalized"
69 );
70
71 Ok(canonicalized)
72 }
73
74 /// Canonicalize an experiment version to standard format
75 ///
76 /// This method determines whether the input is numeric or string and
77 /// applies the appropriate canonicalization rules:
78 ///
79 /// - **Numeric**: Zero-pad to 4 digits (supports up to 9999)
80 /// - **String**: Convert to lowercase for consistency
81 ///
82 /// # Arguments
83 /// * `value` - The experiment version value to canonicalize
84 ///
85 /// # Returns
86 /// * `String` - The canonicalized experiment version
87 ///
88 /// # Numeric Canonicalization
89 /// Numeric experiment versions are zero-padded to exactly 4 digits:
90 /// - 1 → "0001"
91 /// - 42 → "0042"
92 /// - 123 → "0123"
93 /// - 9999 → "9999"
94 ///
95 /// # String Canonicalization
96 /// String experiment versions are converted to lowercase:
97 /// - "PROD" → "prod"
98 /// - "Test" → "test"
99 /// - "dev-branch" → "dev-branch"
100 fn canonicalize_expver(value: &str) -> String {
101 // Try to parse as integer first for numeric canonicalization
102 if let Ok(num) = value.parse::<u32>() {
103 // Zero-pad numeric values to 4 digits
104 format!("{:04}", num)
105 } else {
106 // Convert string values to lowercase for consistency
107 value.to_lowercase()
108 }
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn test_numeric_expver_single_digit() {
118 let result = ExpverHandler::validate_and_canonicalize("1", None, "expver");
119 assert!(result.is_ok());
120 assert_eq!(result.unwrap(), "0001");
121 }
122
123 #[test]
124 fn test_numeric_expver_multiple_digits() {
125 let result = ExpverHandler::validate_and_canonicalize("42", None, "expver");
126 assert!(result.is_ok());
127 assert_eq!(result.unwrap(), "0042");
128 }
129
130 #[test]
131 fn test_numeric_expver_four_digits() {
132 let result = ExpverHandler::validate_and_canonicalize("9999", None, "expver");
133 assert!(result.is_ok());
134 assert_eq!(result.unwrap(), "9999");
135 }
136
137 #[test]
138 fn test_string_expver_uppercase() {
139 let result = ExpverHandler::validate_and_canonicalize("PROD", None, "expver");
140 assert!(result.is_ok());
141 assert_eq!(result.unwrap(), "prod");
142 }
143
144 #[test]
145 fn test_string_expver_mixed_case() {
146 let result = ExpverHandler::validate_and_canonicalize("Test", None, "expver");
147 assert!(result.is_ok());
148 assert_eq!(result.unwrap(), "test");
149 }
150
151 #[test]
152 fn test_empty_with_numeric_default() {
153 let result = ExpverHandler::validate_and_canonicalize("", Some("42"), "expver");
154 assert!(result.is_ok());
155 assert_eq!(result.unwrap(), "0042");
156 }
157
158 #[test]
159 fn test_empty_with_string_default() {
160 let result = ExpverHandler::validate_and_canonicalize("", Some("PROD"), "expver");
161 assert!(result.is_ok());
162 assert_eq!(result.unwrap(), "prod");
163 }
164
165 #[test]
166 fn test_empty_without_default() {
167 let result = ExpverHandler::validate_and_canonicalize("", None, "expver");
168 assert!(result.is_err());
169 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
170 }
171
172 #[test]
173 fn test_alphanumeric_string() {
174 let result = ExpverHandler::validate_and_canonicalize("dev-v2.1", None, "expver");
175 assert!(result.is_ok());
176 assert_eq!(result.unwrap(), "dev-v2.1");
177 }
178}