Skip to main content

copybook_overpunch/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Zoned decimal overpunch encoding and decoding.
4//!
5//! This module provides centralized mapping for zoned decimal overpunch characters
6//! across different codepages (ASCII and EBCDIC variants).
7//!
8//! Overpunch encoding combines the last digit with sign information:
9//! - ASCII: uses letters A-I for positive digits 1-9, J-R for negative digits 1-9
10//! - EBCDIC: uses zone nibbles 0xC (positive) and 0xD (negative)
11//!
12//! Use [`encode_overpunch_byte`] and [`decode_overpunch_byte`] for single-byte
13//! conversion, or the lower-level zone helpers for EBCDIC-specific work.
14//!
15//! ### Overpunch rules (cheatsheet)
16//! - ASCII last-digit:
17//!   - +0..+9 → `{'`, `A`..`I`
18//!   - -0..-9 → `'}'`, `J`..`R`
19//! - EBCDIC last-digit zone:
20//!   - Positive → `0xC`
21//!   - Negative → `0xD`
22//!   - Preferred-zero policy (EBCDIC) → `0xF` for zero regardless of sign
23
24use copybook_codepage::Codepage;
25use copybook_error::{Error, ErrorCode, Result};
26use std::convert::TryFrom;
27
28#[cfg(test)]
29const DEFAULT_PROPTEST_CASE_COUNT: u32 = 512;
30
31/// Policy for the sign zone nibble when the numeric value is exactly zero.
32///
33/// COBOL compilers differ on whether zero is positive (`0xC0`) or unsigned
34/// (`0xF0`). This enum lets callers choose the convention.
35///
36/// # Examples
37///
38/// ```
39/// use copybook_overpunch::ZeroSignPolicy;
40///
41/// let policy = ZeroSignPolicy::Preferred;
42/// assert_eq!(policy, ZeroSignPolicy::Preferred);
43/// assert_ne!(policy, ZeroSignPolicy::Positive);
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ZeroSignPolicy {
47    /// Use positive sign for zero (C for EBCDIC, '{' for ASCII)
48    Positive,
49    /// Use 'F' sign for zero in EBCDIC, positive in ASCII
50    Preferred,
51}
52
53/// ASCII overpunch for the *last* digit in a field.
54/// +0..+9 encode to `{`, `A`..`I`; -0..-9 encode to `}`, `J`..`R`.
55/// Non-final digits MUST be bare ASCII `0`..`9`; helpers enforce this.
56/// Maps digit (0-9) to positive overpunch character.
57static ASCII_POSITIVE_OVERPUNCH: [u8; 10] = [
58    b'{', // 0 -> '{'
59    b'A', // 1 -> 'A'
60    b'B', // 2 -> 'B'
61    b'C', // 3 -> 'C'
62    b'D', // 4 -> 'D'
63    b'E', // 5 -> 'E'
64    b'F', // 6 -> 'F'
65    b'G', // 7 -> 'G'
66    b'H', // 8 -> 'H'
67    b'I', // 9 -> 'I'
68];
69
70/// ASCII negative overpunch lookup table (O(1) access)
71/// Maps digit (0-9) to negative overpunch character
72static ASCII_NEGATIVE_OVERPUNCH: [u8; 10] = [
73    b'}', // 0 -> '}'
74    b'J', // 1 -> 'J'
75    b'K', // 2 -> 'K'
76    b'L', // 3 -> 'L'
77    b'M', // 4 -> 'M'
78    b'N', // 5 -> 'N'
79    b'O', // 6 -> 'O'
80    b'P', // 7 -> 'P'
81    b'Q', // 8 -> 'Q'
82    b'R', // 9 -> 'R'
83];
84
85/// ASCII overpunch decode table: byte -> (digit, `is_negative`)
86/// Uses Option to handle invalid bytes
87static ASCII_OVERPUNCH_DECODE: [Option<(u8, bool)>; 256] = {
88    let mut table = [None; 256];
89
90    // Positive overpunch characters
91    table[b'{' as usize] = Some((0, false));
92    table[b'A' as usize] = Some((1, false));
93    table[b'B' as usize] = Some((2, false));
94    table[b'C' as usize] = Some((3, false));
95    table[b'D' as usize] = Some((4, false));
96    table[b'E' as usize] = Some((5, false));
97    table[b'F' as usize] = Some((6, false));
98    table[b'G' as usize] = Some((7, false));
99    table[b'H' as usize] = Some((8, false));
100    table[b'I' as usize] = Some((9, false));
101
102    // Negative overpunch characters
103    table[b'}' as usize] = Some((0, true));
104    table[b'J' as usize] = Some((1, true));
105    table[b'K' as usize] = Some((2, true));
106    table[b'L' as usize] = Some((3, true));
107    table[b'M' as usize] = Some((4, true));
108    table[b'N' as usize] = Some((5, true));
109    table[b'O' as usize] = Some((6, true));
110    table[b'P' as usize] = Some((7, true));
111    table[b'Q' as usize] = Some((8, true));
112    table[b'R' as usize] = Some((9, true));
113
114    // Regular ASCII digits (0x30-0x39) - unsigned
115    table[b'0' as usize] = Some((0, false));
116    table[b'1' as usize] = Some((1, false));
117    table[b'2' as usize] = Some((2, false));
118    table[b'3' as usize] = Some((3, false));
119    table[b'4' as usize] = Some((4, false));
120    table[b'5' as usize] = Some((5, false));
121    table[b'6' as usize] = Some((6, false));
122    table[b'7' as usize] = Some((7, false));
123    table[b'8' as usize] = Some((8, false));
124    table[b'9' as usize] = Some((9, false));
125
126    table
127};
128
129/// Encode a sign and digit into an EBCDIC overpunch zone byte.
130///
131/// Returns the zone nibble (high 4 bits) for the given digit and sign.
132#[must_use]
133#[inline]
134pub fn encode_ebcdic_overpunch_zone(digit: u8, is_negative: bool, policy: ZeroSignPolicy) -> u8 {
135    debug_assert!(digit <= 9, "Digit must be 0-9");
136
137    if digit == 0 && policy == ZeroSignPolicy::Preferred {
138        return 0xF; // Preferred zone for zero regardless of sign
139    }
140
141    if is_negative {
142        0xD // EBCDIC negative zone
143    } else {
144        0xC // EBCDIC positive zone
145    }
146}
147
148/// Decode an EBCDIC overpunch zone byte into its sign and digit.
149///
150/// Returns `None` for invalid zone nibbles.
151#[must_use]
152#[inline]
153pub const fn decode_ebcdic_overpunch_zone(zone: u8) -> Option<(bool, bool)> {
154    match zone {
155        0xC | 0xF => Some((true, false)), // Positive (preferred for zero)
156        0xD => Some((true, true)),        // Negative
157        _ => None,                        // Invalid zone for signed field
158    }
159}
160
161/// Encode overpunch byte for the given digit, sign, and codepage
162///
163/// # Errors
164/// Returns an error if the digit is invalid or the encoding fails.
165#[inline]
166#[must_use = "Handle the Result or propagate the error"]
167pub fn encode_overpunch_byte(
168    digit: u8,
169    is_negative: bool,
170    codepage: Codepage,
171    policy: ZeroSignPolicy,
172) -> Result<u8> {
173    if digit > 9 {
174        return Err(Error::new(
175            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
176            format!("Invalid digit {digit} for overpunch encoding"),
177        ));
178    }
179
180    if codepage == Codepage::ASCII {
181        // O(1) ASCII overpunch lookup using direct array indexing
182        if digit <= 9 {
183            let byte_value = if is_negative {
184                ASCII_NEGATIVE_OVERPUNCH[digit as usize]
185            } else {
186                ASCII_POSITIVE_OVERPUNCH[digit as usize]
187            };
188            Ok(byte_value)
189        } else {
190            Err(Error::new(
191                ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
192                format!("Invalid digit {digit} for ASCII overpunch encoding"),
193            ))
194        }
195    } else {
196        // EBCDIC: combine zone and digit nibbles
197        let zone = encode_ebcdic_overpunch_zone(digit, is_negative, policy);
198        Ok((zone << 4) | digit)
199    }
200}
201
202/// Decode overpunch byte to extract digit and sign information
203///
204/// # Errors
205/// Returns an error if the byte is not a valid overpunch character.
206#[inline]
207#[must_use = "Handle the Result or propagate the error"]
208pub fn decode_overpunch_byte(byte: u8, codepage: Codepage) -> Result<(u8, bool)> {
209    if codepage == Codepage::ASCII {
210        if let Some((digit, is_negative)) = ASCII_OVERPUNCH_DECODE[byte as usize] {
211            Ok((digit, is_negative))
212        } else {
213            Err(Error::new(
214                ErrorCode::CBKD411_ZONED_BAD_SIGN,
215                format!("Invalid ASCII overpunch byte 0x{byte:02X}"),
216            ))
217        }
218    } else {
219        // EBCDIC: extract zone and digit nibbles
220        let zone = (byte >> 4) & 0x0F;
221        let digit = byte & 0x0F;
222
223        if digit > 9 {
224            return Err(Error::new(
225                ErrorCode::CBKD411_ZONED_BAD_SIGN,
226                format!("Invalid digit nibble 0x{digit:X} in EBCDIC overpunch byte 0x{byte:02X}"),
227            ));
228        }
229
230        if let Some((is_signed, is_negative)) = decode_ebcdic_overpunch_zone(zone) {
231            if !is_signed {
232                return Err(Error::new(
233                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
234                    format!("Unsigned zone 0x{zone:X} in signed EBCDIC field"),
235                ));
236            }
237            Ok((digit, is_negative))
238        } else {
239            Err(Error::new(
240                ErrorCode::CBKD411_ZONED_BAD_SIGN,
241                format!("Invalid EBCDIC zone nibble 0x{zone:X} in byte 0x{byte:02X}"),
242            ))
243        }
244    }
245}
246
247/// Returns `true` if `byte` is a recognised EBCDIC overpunch zone byte.
248#[must_use]
249#[inline]
250pub fn is_valid_overpunch(byte: u8, codepage: Codepage) -> bool {
251    if codepage == Codepage::ASCII {
252        ASCII_OVERPUNCH_DECODE[byte as usize].is_some()
253    } else {
254        let zone = (byte >> 4) & 0x0F;
255        let digit = byte & 0x0F;
256        digit <= 9 && decode_ebcdic_overpunch_zone(zone).is_some()
257    }
258}
259
260/// Returns every EBCDIC byte value that is a valid overpunch zone.
261#[must_use]
262#[inline]
263pub fn get_all_valid_overpunch_bytes(codepage: Codepage) -> Vec<u8> {
264    if codepage == Codepage::ASCII {
265        ASCII_OVERPUNCH_DECODE
266            .iter()
267            .enumerate()
268            .filter_map(|(byte, mapping)| mapping.and_then(|_| u8::try_from(byte).ok()))
269            .collect()
270    } else {
271        let mut bytes = Vec::new();
272        for zone in [0xC, 0xD, 0xF] {
273            for digit in 0..=9 {
274                bytes.push((zone << 4) | digit);
275            }
276        }
277        bytes
278    }
279}
280
281#[cfg(test)]
282#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
283mod tests {
284    use super::*;
285    use proptest::prelude::*;
286    use proptest::test_runner::RngSeed;
287    use std::collections::hash_map::DefaultHasher;
288    use std::hash::{Hash, Hasher};
289
290    fn proptest_case_count() -> u32 {
291        option_env!("PROPTEST_CASES")
292            .and_then(|s| s.parse().ok())
293            .unwrap_or(DEFAULT_PROPTEST_CASE_COUNT)
294    }
295
296    fn zoned_overpunch_proptest_config() -> ProptestConfig {
297        let mut cfg = ProptestConfig {
298            cases: proptest_case_count(),
299            max_shrink_time: 0,
300            ..ProptestConfig::default()
301        };
302
303        if let Ok(seed_value) = std::env::var("PROPTEST_SEED")
304            && !seed_value.is_empty()
305        {
306            let parsed_seed = seed_value.parse::<u64>().unwrap_or_else(|_| {
307                let mut hasher = DefaultHasher::new();
308                seed_value.hash(&mut hasher);
309                hasher.finish()
310            });
311            cfg.rng_seed = RngSeed::Fixed(parsed_seed);
312        }
313
314        cfg
315    }
316
317    proptest! {
318        #![proptest_config(zoned_overpunch_proptest_config())]
319        #[test]
320        fn prop_encode_decode_parity(
321            digit in 0u8..=9,
322            is_negative in any::<bool>(),
323            codepage in prop_oneof![
324                Just(Codepage::ASCII),
325                Just(Codepage::CP037),
326                Just(Codepage::CP273),
327                Just(Codepage::CP500),
328                Just(Codepage::CP1047),
329                Just(Codepage::CP1140),
330            ],
331            policy in prop_oneof![Just(ZeroSignPolicy::Positive), Just(ZeroSignPolicy::Preferred)],
332        ) {
333            let encoded = encode_overpunch_byte(digit, is_negative, codepage, policy)
334                .expect("encoding should succeed for digits 0-9");
335
336            prop_assert!(is_valid_overpunch(encoded, codepage));
337
338            let (decoded_digit, decoded_negative) =
339                decode_overpunch_byte(encoded, codepage).expect("decode must succeed for encoded byte");
340
341            prop_assert_eq!(decoded_digit, digit);
342
343            let expected_negative = if codepage.is_ebcdic()
344                && policy == ZeroSignPolicy::Preferred
345                && digit == 0
346            {
347                // Preferred-zero policy always decodes as positive regardless of requested sign
348                false
349            } else {
350                is_negative
351            };
352
353            prop_assert_eq!(decoded_negative, expected_negative);
354        }
355    }
356
357    #[test]
358    fn test_ascii_overpunch_encode_decode() {
359        // Test positive digits
360        for digit in 0..=9 {
361            let encoded =
362                encode_overpunch_byte(digit, false, Codepage::ASCII, ZeroSignPolicy::Positive)
363                    .expect("Failed to encode positive digit");
364            let (decoded_digit, is_negative) = decode_overpunch_byte(encoded, Codepage::ASCII)
365                .expect("Failed to decode positive digit");
366
367            assert_eq!(decoded_digit, digit);
368            assert!(!is_negative);
369        }
370
371        // Test negative digits
372        for digit in 0..=9 {
373            let encoded =
374                encode_overpunch_byte(digit, true, Codepage::ASCII, ZeroSignPolicy::Positive)
375                    .expect("Failed to encode negative digit");
376            let (decoded_digit, is_negative) = decode_overpunch_byte(encoded, Codepage::ASCII)
377                .expect("Failed to decode negative digit");
378
379            assert_eq!(decoded_digit, digit);
380            assert!(is_negative);
381        }
382    }
383
384    #[test]
385    fn test_ebcdic_overpunch_encode_decode() {
386        // Test positive digits with positive policy
387        for digit in 0..=9 {
388            let encoded =
389                encode_overpunch_byte(digit, false, Codepage::CP037, ZeroSignPolicy::Positive)
390                    .expect("Failed to encode positive digit");
391            let (decoded_digit, is_negative) = decode_overpunch_byte(encoded, Codepage::CP037)
392                .expect("Failed to decode positive digit");
393
394            assert_eq!(decoded_digit, digit);
395            assert!(!is_negative);
396
397            // Check zone nibble is 0xC for positive
398            let zone = (encoded >> 4) & 0x0F;
399            if digit != 0 {
400                assert_eq!(zone, 0xC);
401            }
402        }
403
404        // Test negative digits
405        for digit in 0..=9 {
406            let encoded =
407                encode_overpunch_byte(digit, true, Codepage::CP037, ZeroSignPolicy::Positive)
408                    .expect("Failed to encode negative digit");
409            let (decoded_digit, is_negative) = decode_overpunch_byte(encoded, Codepage::CP037)
410                .expect("Failed to decode negative digit");
411
412            assert_eq!(decoded_digit, digit);
413            assert!(is_negative);
414
415            // Check zone nibble is 0xD for negative
416            let zone = (encoded >> 4) & 0x0F;
417            assert_eq!(zone, 0xD);
418        }
419    }
420
421    #[test]
422    fn test_zero_sign_policies() {
423        // Test positive policy for zero
424        let encoded_pos =
425            encode_overpunch_byte(0, false, Codepage::CP037, ZeroSignPolicy::Positive)
426                .expect("Failed to encode zero with positive policy");
427        let zone_pos = (encoded_pos >> 4) & 0x0F;
428        assert_eq!(zone_pos, 0xC);
429
430        // Test preferred policy for zero (should use 0xF regardless of sign)
431        let encoded_pref_pos =
432            encode_overpunch_byte(0, false, Codepage::CP037, ZeroSignPolicy::Preferred)
433                .expect("Failed to encode zero with preferred policy");
434        let zone_pref_pos = (encoded_pref_pos >> 4) & 0x0F;
435        assert_eq!(zone_pref_pos, 0xF);
436
437        let encoded_pref_neg =
438            encode_overpunch_byte(0, true, Codepage::CP037, ZeroSignPolicy::Preferred)
439                .expect("Failed to encode negative zero with preferred policy");
440        let zone_pref_neg = (encoded_pref_neg >> 4) & 0x0F;
441        assert_eq!(zone_pref_neg, 0xF);
442    }
443
444    #[test]
445    fn test_invalid_inputs() {
446        // Test invalid digit
447        let result = encode_overpunch_byte(10, false, Codepage::ASCII, ZeroSignPolicy::Positive);
448        assert!(result.is_err());
449
450        // Test invalid ASCII byte
451        let result = decode_overpunch_byte(0xFF, Codepage::ASCII);
452        assert!(result.is_err());
453
454        // Test invalid EBCDIC zone
455        let invalid_ebcdic = 0x1F; // Zone 0x1, digit 0xF
456        let result = decode_overpunch_byte(invalid_ebcdic, Codepage::CP037);
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn test_ascii_overpunch_golden_values() {
462        // Test specific ASCII overpunch values
463        assert_eq!(
464            decode_overpunch_byte(b'{', Codepage::ASCII).unwrap(),
465            (0, false)
466        ); // +0
467        assert_eq!(
468            decode_overpunch_byte(b'A', Codepage::ASCII).unwrap(),
469            (1, false)
470        ); // +1
471        assert_eq!(
472            decode_overpunch_byte(b'I', Codepage::ASCII).unwrap(),
473            (9, false)
474        ); // +9
475        assert_eq!(
476            decode_overpunch_byte(b'}', Codepage::ASCII).unwrap(),
477            (0, true)
478        ); // -0
479        assert_eq!(
480            decode_overpunch_byte(b'J', Codepage::ASCII).unwrap(),
481            (1, true)
482        ); // -1
483        assert_eq!(
484            decode_overpunch_byte(b'R', Codepage::ASCII).unwrap(),
485            (9, true)
486        ); // -9
487    }
488
489    #[test]
490    fn test_ebcdic_overpunch_golden_values() {
491        // Test specific EBCDIC overpunch values
492        assert_eq!(
493            decode_overpunch_byte(0xC0, Codepage::CP037).unwrap(),
494            (0, false)
495        ); // +0
496        assert_eq!(
497            decode_overpunch_byte(0xC1, Codepage::CP037).unwrap(),
498            (1, false)
499        ); // +1
500        assert_eq!(
501            decode_overpunch_byte(0xC9, Codepage::CP037).unwrap(),
502            (9, false)
503        ); // +9
504        assert_eq!(
505            decode_overpunch_byte(0xD0, Codepage::CP037).unwrap(),
506            (0, true)
507        ); // -0
508        assert_eq!(
509            decode_overpunch_byte(0xD1, Codepage::CP037).unwrap(),
510            (1, true)
511        ); // -1
512        assert_eq!(
513            decode_overpunch_byte(0xD9, Codepage::CP037).unwrap(),
514            (9, true)
515        ); // -9
516        assert_eq!(
517            decode_overpunch_byte(0xF0, Codepage::CP037).unwrap(),
518            (0, false)
519        ); // Preferred 0
520    }
521
522    #[test]
523    fn test_is_valid_overpunch() {
524        // ASCII tests
525        assert!(is_valid_overpunch(b'A', Codepage::ASCII));
526        assert!(is_valid_overpunch(b'J', Codepage::ASCII));
527        assert!(is_valid_overpunch(b'{', Codepage::ASCII));
528        assert!(is_valid_overpunch(b'0', Codepage::ASCII)); // Regular digit
529        assert!(!is_valid_overpunch(b'Z', Codepage::ASCII));
530
531        // EBCDIC tests
532        assert!(is_valid_overpunch(0xC0, Codepage::CP037));
533        assert!(is_valid_overpunch(0xD9, Codepage::CP037));
534        assert!(is_valid_overpunch(0xF5, Codepage::CP037));
535        assert!(!is_valid_overpunch(0x1A, Codepage::CP037)); // Invalid zone
536        assert!(!is_valid_overpunch(0xCA, Codepage::CP037)); // Invalid digit
537    }
538
539    #[test]
540    fn test_get_all_valid_overpunch_bytes() {
541        let ascii_bytes = get_all_valid_overpunch_bytes(Codepage::ASCII);
542        assert!(ascii_bytes.len() >= 30); // At least 20 overpunch + 10 regular digits
543        assert!(ascii_bytes.contains(&b'A'));
544        assert!(ascii_bytes.contains(&b'J'));
545        assert!(ascii_bytes.contains(&b'0'));
546
547        let ebcdic_bytes = get_all_valid_overpunch_bytes(Codepage::CP037);
548        assert_eq!(ebcdic_bytes.len(), 30); // 3 zones * 10 digits each
549        assert!(ebcdic_bytes.contains(&0xC0));
550        assert!(ebcdic_bytes.contains(&0xD9));
551        assert!(ebcdic_bytes.contains(&0xF5));
552    }
553
554    // ── Exhaustive overpunch tests ──────────────────────────────────────
555
556    #[test]
557    fn test_ascii_decode_each_positive_overpunch_char() {
558        let expected: [(u8, u8); 10] = [
559            (b'{', 0),
560            (b'A', 1),
561            (b'B', 2),
562            (b'C', 3),
563            (b'D', 4),
564            (b'E', 5),
565            (b'F', 6),
566            (b'G', 7),
567            (b'H', 8),
568            (b'I', 9),
569        ];
570        for (byte, digit) in expected {
571            let (d, neg) = decode_overpunch_byte(byte, Codepage::ASCII).unwrap();
572            assert_eq!(d, digit, "positive overpunch char 0x{byte:02X}");
573            assert!(
574                !neg,
575                "positive overpunch char 0x{byte:02X} should not be negative"
576            );
577        }
578    }
579
580    #[test]
581    fn test_ascii_decode_each_negative_overpunch_char() {
582        let expected: [(u8, u8); 10] = [
583            (b'}', 0),
584            (b'J', 1),
585            (b'K', 2),
586            (b'L', 3),
587            (b'M', 4),
588            (b'N', 5),
589            (b'O', 6),
590            (b'P', 7),
591            (b'Q', 8),
592            (b'R', 9),
593        ];
594        for (byte, digit) in expected {
595            let (d, neg) = decode_overpunch_byte(byte, Codepage::ASCII).unwrap();
596            assert_eq!(d, digit, "negative overpunch char 0x{byte:02X}");
597            assert!(
598                neg,
599                "negative overpunch char 0x{byte:02X} should be negative"
600            );
601        }
602    }
603
604    #[test]
605    fn test_ascii_encode_each_digit_positive() {
606        let expected_bytes: [u8; 10] = [b'{', b'A', b'B', b'C', b'D', b'E', b'F', b'G', b'H', b'I'];
607        for digit in 0u8..=9 {
608            let enc =
609                encode_overpunch_byte(digit, false, Codepage::ASCII, ZeroSignPolicy::Positive)
610                    .unwrap();
611            assert_eq!(
612                enc, expected_bytes[digit as usize],
613                "encode positive digit {digit}"
614            );
615        }
616    }
617
618    #[test]
619    fn test_ascii_encode_each_digit_negative() {
620        let expected_bytes: [u8; 10] = [b'}', b'J', b'K', b'L', b'M', b'N', b'O', b'P', b'Q', b'R'];
621        for digit in 0u8..=9 {
622            let enc = encode_overpunch_byte(digit, true, Codepage::ASCII, ZeroSignPolicy::Positive)
623                .unwrap();
624            assert_eq!(
625                enc, expected_bytes[digit as usize],
626                "encode negative digit {digit}"
627            );
628        }
629    }
630
631    #[test]
632    fn test_all_ebcdic_codepage_variants_encode_decode() {
633        let ebcdic_codepages = [
634            Codepage::CP037,
635            Codepage::CP273,
636            Codepage::CP500,
637            Codepage::CP1047,
638            Codepage::CP1140,
639        ];
640        for cp in ebcdic_codepages {
641            for digit in 0u8..=9 {
642                for is_negative in [false, true] {
643                    let enc =
644                        encode_overpunch_byte(digit, is_negative, cp, ZeroSignPolicy::Positive)
645                            .unwrap_or_else(|e| {
646                                panic!("{cp:?} digit={digit} neg={is_negative}: {e}")
647                            });
648                    let (d, neg) = decode_overpunch_byte(enc, cp)
649                        .unwrap_or_else(|e| panic!("{cp:?} byte=0x{enc:02X}: {e}"));
650                    assert_eq!(d, digit, "{cp:?} digit={digit} neg={is_negative}");
651                    assert_eq!(neg, is_negative, "{cp:?} digit={digit} neg={is_negative}");
652                }
653            }
654        }
655    }
656
657    #[test]
658    fn test_ebcdic_zone_nibbles_all_codepages() {
659        let ebcdic_codepages = [
660            Codepage::CP037,
661            Codepage::CP273,
662            Codepage::CP500,
663            Codepage::CP1047,
664            Codepage::CP1140,
665        ];
666        for cp in ebcdic_codepages {
667            for digit in 0u8..=9 {
668                let pos =
669                    encode_overpunch_byte(digit, false, cp, ZeroSignPolicy::Positive).unwrap();
670                assert_eq!(
671                    (pos >> 4) & 0x0F,
672                    0xC,
673                    "{cp:?} positive zone for digit {digit}"
674                );
675                assert_eq!(
676                    pos & 0x0F,
677                    digit,
678                    "{cp:?} positive digit nibble for {digit}"
679                );
680
681                let neg = encode_overpunch_byte(digit, true, cp, ZeroSignPolicy::Positive).unwrap();
682                assert_eq!(
683                    (neg >> 4) & 0x0F,
684                    0xD,
685                    "{cp:?} negative zone for digit {digit}"
686                );
687                assert_eq!(
688                    neg & 0x0F,
689                    digit,
690                    "{cp:?} negative digit nibble for {digit}"
691                );
692            }
693        }
694    }
695
696    #[test]
697    fn test_invalid_ascii_overpunch_bytes() {
698        let invalid_bytes: [u8; 10] = [b'S', b'T', b'Z', b'a', b'z', b'!', b'@', b'#', 0x00, 0xFF];
699        for byte in invalid_bytes {
700            let result = decode_overpunch_byte(byte, Codepage::ASCII);
701            assert!(
702                result.is_err(),
703                "byte 0x{byte:02X} should be invalid ASCII overpunch"
704            );
705            assert!(!is_valid_overpunch(byte, Codepage::ASCII));
706        }
707    }
708
709    #[test]
710    fn test_invalid_ebcdic_overpunch_bytes() {
711        // Invalid zone nibbles (not 0xC, 0xD, or 0xF)
712        let invalid_zones: [u8; 6] = [0x0, 0x1, 0x2, 0xA, 0xB, 0xE];
713        for zone in invalid_zones {
714            let byte = (zone << 4) | 0x05; // digit 5 with invalid zone
715            let result = decode_overpunch_byte(byte, Codepage::CP037);
716            assert!(
717                result.is_err(),
718                "zone 0x{zone:X} should be invalid for EBCDIC"
719            );
720            assert!(!is_valid_overpunch(byte, Codepage::CP037));
721        }
722        // Invalid digit nibbles (> 9)
723        for digit_nibble in 0xAu8..=0xF {
724            let byte = (0xC << 4) | digit_nibble; // positive zone with invalid digit
725            let result = decode_overpunch_byte(byte, Codepage::CP037);
726            assert!(
727                result.is_err(),
728                "digit nibble 0x{digit_nibble:X} should be invalid"
729            );
730            assert!(!is_valid_overpunch(byte, Codepage::CP037));
731        }
732    }
733
734    #[test]
735    fn test_encode_invalid_digit_rejected() {
736        for digit in [10u8, 11, 15, 99, 255] {
737            let r1 = encode_overpunch_byte(digit, false, Codepage::ASCII, ZeroSignPolicy::Positive);
738            assert!(r1.is_err(), "digit {digit} should be rejected for ASCII");
739            let r2 = encode_overpunch_byte(digit, false, Codepage::CP037, ZeroSignPolicy::Positive);
740            assert!(r2.is_err(), "digit {digit} should be rejected for EBCDIC");
741        }
742    }
743
744    #[test]
745    fn test_ascii_round_trip_all_digit_sign_combinations() {
746        for digit in 0u8..=9 {
747            for is_negative in [false, true] {
748                let enc = encode_overpunch_byte(
749                    digit,
750                    is_negative,
751                    Codepage::ASCII,
752                    ZeroSignPolicy::Positive,
753                )
754                .unwrap();
755                let (d, neg) = decode_overpunch_byte(enc, Codepage::ASCII).unwrap();
756                assert_eq!(d, digit);
757                assert_eq!(neg, is_negative);
758            }
759        }
760    }
761
762    #[test]
763    fn test_ebcdic_preferred_zero_round_trip_all_codepages() {
764        let ebcdic_codepages = [
765            Codepage::CP037,
766            Codepage::CP273,
767            Codepage::CP500,
768            Codepage::CP1047,
769            Codepage::CP1140,
770        ];
771        for cp in ebcdic_codepages {
772            // Preferred zero: both pos and neg zero encode to 0xF0, decode as positive
773            let enc_pos = encode_overpunch_byte(0, false, cp, ZeroSignPolicy::Preferred).unwrap();
774            let enc_neg = encode_overpunch_byte(0, true, cp, ZeroSignPolicy::Preferred).unwrap();
775            assert_eq!(enc_pos, 0xF0, "{cp:?} preferred +0");
776            assert_eq!(enc_neg, 0xF0, "{cp:?} preferred -0");
777
778            let (d_pos, neg_pos) = decode_overpunch_byte(enc_pos, cp).unwrap();
779            assert_eq!(d_pos, 0);
780            assert!(!neg_pos, "{cp:?} preferred zero decodes as positive");
781
782            // Non-zero digits should not be affected by preferred policy
783            for digit in 1u8..=9 {
784                let enc =
785                    encode_overpunch_byte(digit, true, cp, ZeroSignPolicy::Preferred).unwrap();
786                let zone = (enc >> 4) & 0x0F;
787                assert_eq!(
788                    zone, 0xD,
789                    "{cp:?} preferred policy should not affect digit {digit}"
790                );
791            }
792        }
793    }
794
795    #[test]
796    fn test_positive_and_negative_zero_ascii() {
797        // Positive zero
798        let enc_pos =
799            encode_overpunch_byte(0, false, Codepage::ASCII, ZeroSignPolicy::Positive).unwrap();
800        assert_eq!(enc_pos, b'{');
801        let (d, neg) = decode_overpunch_byte(enc_pos, Codepage::ASCII).unwrap();
802        assert_eq!(d, 0);
803        assert!(!neg);
804
805        // Negative zero
806        let enc_neg =
807            encode_overpunch_byte(0, true, Codepage::ASCII, ZeroSignPolicy::Positive).unwrap();
808        assert_eq!(enc_neg, b'}');
809        let (d, neg) = decode_overpunch_byte(enc_neg, Codepage::ASCII).unwrap();
810        assert_eq!(d, 0);
811        assert!(neg);
812    }
813
814    #[test]
815    fn test_positive_and_negative_zero_ebcdic() {
816        // Positive zero with Positive policy -> zone 0xC
817        let enc =
818            encode_overpunch_byte(0, false, Codepage::CP037, ZeroSignPolicy::Positive).unwrap();
819        assert_eq!(enc, 0xC0);
820        let (d, neg) = decode_overpunch_byte(enc, Codepage::CP037).unwrap();
821        assert_eq!(d, 0);
822        assert!(!neg);
823
824        // Negative zero with Positive policy -> zone 0xD
825        let enc =
826            encode_overpunch_byte(0, true, Codepage::CP037, ZeroSignPolicy::Positive).unwrap();
827        assert_eq!(enc, 0xD0);
828        let (d, neg) = decode_overpunch_byte(enc, Codepage::CP037).unwrap();
829        assert_eq!(d, 0);
830        assert!(neg);
831
832        // Preferred zero (either sign) -> zone 0xF, decodes positive
833        let enc =
834            encode_overpunch_byte(0, true, Codepage::CP037, ZeroSignPolicy::Preferred).unwrap();
835        assert_eq!(enc, 0xF0);
836        let (d, neg) = decode_overpunch_byte(enc, Codepage::CP037).unwrap();
837        assert_eq!(d, 0);
838        assert!(!neg, "preferred zero always decodes as positive");
839    }
840
841    #[test]
842    fn test_ascii_regular_digits_decode_as_unsigned_positive() {
843        // Plain ASCII digits 0-9 are valid overpunch and decode as positive
844        for digit in 0u8..=9 {
845            let byte = b'0' + digit;
846            let (d, neg) = decode_overpunch_byte(byte, Codepage::ASCII).unwrap();
847            assert_eq!(d, digit);
848            assert!(!neg, "plain digit {digit} should decode as positive");
849            assert!(is_valid_overpunch(byte, Codepage::ASCII));
850        }
851    }
852
853    #[test]
854    fn test_ebcdic_f_zone_digits_decode_as_positive() {
855        // 0xF zone (unsigned/preferred) decodes as positive for all digits
856        for digit in 0u8..=9 {
857            let byte = 0xF0 | digit;
858            let (d, neg) = decode_overpunch_byte(byte, Codepage::CP037).unwrap();
859            assert_eq!(d, digit);
860            assert!(!neg, "F-zone digit {digit} should decode as positive");
861        }
862    }
863
864    #[test]
865    fn test_boundary_single_digit_encode_decode() {
866        // Boundary: smallest (0) and largest (9) single digit
867        for &(digit, is_neg) in &[(0u8, false), (0, true), (9, false), (9, true)] {
868            let ascii_enc =
869                encode_overpunch_byte(digit, is_neg, Codepage::ASCII, ZeroSignPolicy::Positive)
870                    .unwrap();
871            let (d, n) = decode_overpunch_byte(ascii_enc, Codepage::ASCII).unwrap();
872            assert_eq!(d, digit);
873            assert_eq!(n, is_neg);
874
875            let ebcdic_enc =
876                encode_overpunch_byte(digit, is_neg, Codepage::CP037, ZeroSignPolicy::Positive)
877                    .unwrap();
878            let (d, n) = decode_overpunch_byte(ebcdic_enc, Codepage::CP037).unwrap();
879            assert_eq!(d, digit);
880            assert_eq!(n, is_neg);
881        }
882    }
883
884    #[test]
885    fn test_ascii_preferred_zero_policy_has_no_effect() {
886        // ASCII ignores ZeroSignPolicy – the encode always uses the sign tables
887        let pos =
888            encode_overpunch_byte(0, false, Codepage::ASCII, ZeroSignPolicy::Preferred).unwrap();
889        let neg =
890            encode_overpunch_byte(0, true, Codepage::ASCII, ZeroSignPolicy::Preferred).unwrap();
891        assert_eq!(pos, b'{', "ASCII preferred +0 should still be '{{' ");
892        assert_eq!(neg, b'}', "ASCII preferred -0 should still be '}}' ");
893    }
894}