promocrypt_core/
validator.rs

1//! Code validation module.
2//!
3//! Provides functions for validating promotional codes, checking format,
4//! character validity, and Damm check digits.
5
6use crate::alphabet::Alphabet;
7use crate::damm::DammTable;
8use crate::error::ValidationResult;
9
10/// Validate a promotional code and return detailed result.
11///
12/// This function checks:
13/// 1. Code length matches expected length
14/// 2. All characters are in the alphabet
15/// 3. Damm check digit is correct
16///
17/// # Arguments
18/// * `code` - The code to validate (including check digit)
19/// * `alphabet` - The alphabet used for the code
20/// * `expected_length` - Expected total length (code_length + 1 for check digit)
21/// * `damm_table` - Damm table for check digit validation
22///
23/// # Returns
24/// `ValidationResult` with detailed information about any validation failure.
25///
26/// # Example
27///
28/// ```
29/// use promocrypt_core::{validate, Alphabet, DammTable, ValidationResult};
30///
31/// let alphabet = Alphabet::default_alphabet();
32/// let damm = DammTable::new(alphabet.len());
33///
34/// let result = validate("INVALIDCODE", &alphabet, 10, &damm);
35/// // result will indicate specific failure reason
36/// ```
37pub fn validate(
38    code: &str,
39    alphabet: &Alphabet,
40    expected_length: usize,
41    damm_table: &DammTable,
42) -> ValidationResult {
43    // 1. Check length
44    let actual_length = code.chars().count();
45    if actual_length != expected_length {
46        return ValidationResult::InvalidLength {
47            expected: expected_length,
48            actual: actual_length,
49        };
50    }
51
52    // 2. Check all characters are in alphabet
53    for (pos, c) in code.chars().enumerate() {
54        if !alphabet.contains(c) {
55            return ValidationResult::InvalidCharacter {
56                char: c,
57                position: pos,
58            };
59        }
60    }
61
62    // 3. Validate Damm check digit
63    if !damm_table.validate(code, alphabet) {
64        return ValidationResult::InvalidCheckDigit;
65    }
66
67    ValidationResult::Valid
68}
69
70/// Quick boolean validation check.
71///
72/// Returns `true` if the code is valid, `false` otherwise.
73/// Use `validate()` if you need details about why validation failed.
74///
75/// # Example
76///
77/// ```
78/// use promocrypt_core::{is_valid, Alphabet, DammTable};
79///
80/// let alphabet = Alphabet::default_alphabet();
81/// let damm = DammTable::new(alphabet.len());
82///
83/// let valid = is_valid("SOMECODE", &alphabet, 10, &damm);
84/// ```
85#[inline]
86pub fn is_valid(
87    code: &str,
88    alphabet: &Alphabet,
89    expected_length: usize,
90    damm_table: &DammTable,
91) -> bool {
92    validate(code, alphabet, expected_length, damm_table).is_valid()
93}
94
95/// Validate code format only (length and characters), without checking the check digit.
96///
97/// Useful for quick format validation before more expensive operations.
98pub fn validate_format(
99    code: &str,
100    alphabet: &Alphabet,
101    expected_length: usize,
102) -> ValidationResult {
103    // 1. Check length
104    let actual_length = code.chars().count();
105    if actual_length != expected_length {
106        return ValidationResult::InvalidLength {
107            expected: expected_length,
108            actual: actual_length,
109        };
110    }
111
112    // 2. Check all characters are in alphabet
113    for (pos, c) in code.chars().enumerate() {
114        if !alphabet.contains(c) {
115            return ValidationResult::InvalidCharacter {
116                char: c,
117                position: pos,
118            };
119        }
120    }
121
122    ValidationResult::Valid
123}
124
125/// Batch validation of multiple codes.
126///
127/// Returns a vector of validation results, one for each input code.
128pub fn validate_batch(
129    codes: &[&str],
130    alphabet: &Alphabet,
131    expected_length: usize,
132    damm_table: &DammTable,
133) -> Vec<ValidationResult> {
134    codes
135        .iter()
136        .map(|code| validate(code, alphabet, expected_length, damm_table))
137        .collect()
138}
139
140/// Count valid codes in a batch.
141///
142/// More efficient than validating all and counting, as it can short-circuit.
143pub fn count_valid(
144    codes: &[&str],
145    alphabet: &Alphabet,
146    expected_length: usize,
147    damm_table: &DammTable,
148) -> usize {
149    codes
150        .iter()
151        .filter(|code| is_valid(code, alphabet, expected_length, damm_table))
152        .count()
153}
154
155/// Partition codes into valid and invalid.
156///
157/// Returns (valid_codes, invalid_codes_with_reasons).
158pub fn partition_codes<'a>(
159    codes: &[&'a str],
160    alphabet: &Alphabet,
161    expected_length: usize,
162    damm_table: &DammTable,
163) -> (Vec<&'a str>, Vec<(&'a str, ValidationResult)>) {
164    let mut valid = Vec::new();
165    let mut invalid = Vec::new();
166
167    for &code in codes {
168        let result = validate(code, alphabet, expected_length, damm_table);
169        if result.is_valid() {
170            valid.push(code);
171        } else {
172            invalid.push((code, result));
173        }
174    }
175
176    (valid, invalid)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::generator::{CheckPosition, generate_code};
183
184    fn setup() -> (Alphabet, DammTable, [u8; 32]) {
185        let alphabet = Alphabet::default_alphabet();
186        let damm = DammTable::new(alphabet.len());
187        let secret = [42u8; 32];
188        (alphabet, damm, secret)
189    }
190
191    #[test]
192    fn test_validate_generated_code() {
193        let (alphabet, damm, secret) = setup();
194
195        let code = generate_code(&secret, 0, &alphabet, 9, CheckPosition::End, &damm);
196        let result = validate(&code, &alphabet, 10, &damm);
197
198        assert!(result.is_valid());
199    }
200
201    #[test]
202    fn test_validate_invalid_length_short() {
203        let (alphabet, damm, _) = setup();
204
205        let result = validate("ABC", &alphabet, 10, &damm);
206        assert!(matches!(
207            result,
208            ValidationResult::InvalidLength {
209                expected: 10,
210                actual: 3
211            }
212        ));
213    }
214
215    #[test]
216    fn test_validate_invalid_length_long() {
217        let (alphabet, damm, _) = setup();
218
219        let result = validate("ABCDEFGHIJKLMNO", &alphabet, 10, &damm);
220        assert!(matches!(
221            result,
222            ValidationResult::InvalidLength {
223                expected: 10,
224                actual: 15
225            }
226        ));
227    }
228
229    #[test]
230    fn test_validate_invalid_character() {
231        let (alphabet, damm, _) = setup();
232
233        // '!' is not in the default alphabet
234        let result = validate("ABCDEFGH!J", &alphabet, 10, &damm);
235        assert!(matches!(
236            result,
237            ValidationResult::InvalidCharacter {
238                char: '!',
239                position: 8
240            }
241        ));
242    }
243
244    #[test]
245    fn test_validate_invalid_checkdigit() {
246        let (alphabet, damm, secret) = setup();
247
248        // Generate a valid code
249        let code = generate_code(&secret, 0, &alphabet, 9, CheckPosition::End, &damm);
250
251        // Modify one character (not the check digit) to make it invalid
252        let mut chars: Vec<char> = code.chars().collect();
253        chars[4] = if chars[4] == 'A' { 'B' } else { 'A' };
254        let invalid_code: String = chars.iter().collect();
255
256        let result = validate(&invalid_code, &alphabet, 10, &damm);
257        assert!(matches!(result, ValidationResult::InvalidCheckDigit));
258    }
259
260    #[test]
261    fn test_is_valid() {
262        let (alphabet, damm, secret) = setup();
263
264        let code = generate_code(&secret, 0, &alphabet, 9, CheckPosition::End, &damm);
265        assert!(is_valid(&code, &alphabet, 10, &damm));
266
267        assert!(!is_valid("INVALID!!!", &alphabet, 10, &damm));
268    }
269
270    #[test]
271    fn test_validate_format() {
272        let (alphabet, _, _) = setup();
273
274        // Valid format but we're not checking check digit
275        let result = validate_format("ABCDEFGHXY", &alphabet, 10);
276        assert!(result.is_valid());
277
278        // Invalid character
279        let result = validate_format("ABCDEFGH!X", &alphabet, 10);
280        assert!(matches!(result, ValidationResult::InvalidCharacter { .. }));
281    }
282
283    #[test]
284    fn test_validate_batch() {
285        let (alphabet, damm, secret) = setup();
286
287        let code1 = generate_code(&secret, 0, &alphabet, 9, CheckPosition::End, &damm);
288        let code2 = generate_code(&secret, 1, &alphabet, 9, CheckPosition::End, &damm);
289        let invalid = "INVALID!!!";
290
291        let codes: Vec<&str> = vec![&code1, &code2, invalid];
292        let results = validate_batch(&codes, &alphabet, 10, &damm);
293
294        assert!(results[0].is_valid());
295        assert!(results[1].is_valid());
296        assert!(!results[2].is_valid());
297    }
298
299    #[test]
300    fn test_count_valid() {
301        let (alphabet, damm, secret) = setup();
302
303        let code1 = generate_code(&secret, 0, &alphabet, 9, CheckPosition::End, &damm);
304        let code2 = generate_code(&secret, 1, &alphabet, 9, CheckPosition::End, &damm);
305        let invalid = "INVALID!!!";
306
307        let codes: Vec<&str> = vec![&code1, &code2, invalid];
308        let count = count_valid(&codes, &alphabet, 10, &damm);
309
310        assert_eq!(count, 2);
311    }
312
313    #[test]
314    fn test_partition_codes() {
315        let (alphabet, damm, secret) = setup();
316
317        let code1 = generate_code(&secret, 0, &alphabet, 9, CheckPosition::End, &damm);
318        let code2 = generate_code(&secret, 1, &alphabet, 9, CheckPosition::End, &damm);
319        let invalid = "INVALID!!!";
320
321        let codes: Vec<&str> = vec![&code1, &code2, invalid];
322        let (valid, invalid_with_reasons) = partition_codes(&codes, &alphabet, 10, &damm);
323
324        assert_eq!(valid.len(), 2);
325        assert_eq!(invalid_with_reasons.len(), 1);
326        assert_eq!(invalid_with_reasons[0].0, "INVALID!!!");
327    }
328
329    #[test]
330    fn test_validation_result_error_code() {
331        assert_eq!(ValidationResult::Valid.error_code(), 0);
332        assert_eq!(
333            ValidationResult::InvalidLength {
334                expected: 10,
335                actual: 5
336            }
337            .error_code(),
338            1
339        );
340        assert_eq!(
341            ValidationResult::InvalidCharacter {
342                char: '!',
343                position: 0
344            }
345            .error_code(),
346            2
347        );
348        assert_eq!(ValidationResult::InvalidCheckDigit.error_code(), 3);
349    }
350}