Skip to main content

copybook_codec/numeric/
zoned.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 encoding detection helpers used by codec options and framing logic.
4#![allow(clippy::missing_inline_in_public_items)]
5
6use serde::{Deserialize, Serialize};
7use std::{fmt, str::FromStr};
8
9/// Zone nibble constants for zoned decimal encoding detection.
10mod zone_constants {
11    /// ASCII digit zone nibble (0x30-0x39 range).
12    pub const ASCII_ZONE: u8 = 0x3;
13    /// EBCDIC digit zone nibble (0xF0-0xF9 range).
14    pub const EBCDIC_ZONE: u8 = 0xF;
15    /// Zone nibble mask for extracting upper 4 bits.
16    pub const ZONE_MASK: u8 = 0x0F;
17}
18
19/// Zoned decimal encoding format specification for round-trip fidelity.
20///
21/// This enum controls how zoned decimal fields are encoded and decoded,
22/// enabling preservation of the original encoding format during round-trip
23/// operations for enterprise data consistency.
24///
25/// # Examples
26///
27/// ```
28/// use copybook_codec::numeric::zoned::ZonedEncodingFormat;
29///
30/// let fmt = ZonedEncodingFormat::default();
31/// assert!(fmt.is_auto());
32/// assert_eq!(fmt.description(), "Automatic detection based on zone nibbles");
33///
34/// let detected = ZonedEncodingFormat::detect_from_byte(0xF5);
35/// assert_eq!(detected, Some(ZonedEncodingFormat::Ebcdic));
36/// ```
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
38pub enum ZonedEncodingFormat {
39    /// ASCII digit zones (0x30-0x39).
40    Ascii,
41    /// EBCDIC digit zones (0xF0-0xF9).
42    Ebcdic,
43    /// Automatic detection based on zone nibbles.
44    #[default]
45    Auto,
46}
47
48/// Error returned when parsing an unsupported zoned encoding format.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ParseZonedEncodingFormatError {
51    input: String,
52}
53
54impl fmt::Display for ParseZonedEncodingFormatError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "unsupported zoned encoding format `{}`", self.input)
57    }
58}
59
60impl std::error::Error for ParseZonedEncodingFormatError {}
61
62impl FromStr for ZonedEncodingFormat {
63    type Err = ParseZonedEncodingFormatError;
64
65    fn from_str(input: &str) -> Result<Self, Self::Err> {
66        match input.to_ascii_lowercase().as_str() {
67            "ascii" => Ok(Self::Ascii),
68            "ebcdic" => Ok(Self::Ebcdic),
69            "auto" => Ok(Self::Auto),
70            _ => Err(ParseZonedEncodingFormatError {
71                input: input.to_owned(),
72            }),
73        }
74    }
75}
76
77impl ZonedEncodingFormat {
78    /// Check if this is ASCII encoding.
79    #[must_use]
80    #[inline]
81    pub const fn is_ascii(self) -> bool {
82        matches!(self, Self::Ascii)
83    }
84
85    /// Check if this is EBCDIC encoding.
86    #[must_use]
87    #[inline]
88    pub const fn is_ebcdic(self) -> bool {
89        matches!(self, Self::Ebcdic)
90    }
91
92    /// Check if this is auto-detection mode.
93    #[must_use]
94    #[inline]
95    pub const fn is_auto(self) -> bool {
96        matches!(self, Self::Auto)
97    }
98
99    /// Get a human-readable description of the encoding format.
100    #[must_use]
101    #[inline]
102    pub const fn description(self) -> &'static str {
103        match self {
104            Self::Ascii => "ASCII digit zones (0x30-0x39)",
105            Self::Ebcdic => "EBCDIC digit zones (0xF0-0xF9)",
106            Self::Auto => "Automatic detection based on zone nibbles",
107        }
108    }
109
110    /// Detect encoding format from a single byte of zoned decimal data.
111    ///
112    /// Examines the zone nibble (upper 4 bits) to determine the encoding
113    /// format. Returns `None` for invalid zone values.
114    #[must_use]
115    #[inline]
116    pub fn detect_from_byte(byte: u8) -> Option<Self> {
117        let zone_nibble = (byte >> 4) & zone_constants::ZONE_MASK;
118        match zone_nibble {
119            zone_constants::ASCII_ZONE => Some(Self::Ascii),
120            zone_constants::EBCDIC_ZONE => Some(Self::Ebcdic),
121            _ => None,
122        }
123    }
124}
125
126impl fmt::Display for ZonedEncodingFormat {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Ascii => write!(f, "ascii"),
130            Self::Ebcdic => write!(f, "ebcdic"),
131            Self::Auto => write!(f, "auto"),
132        }
133    }
134}
135
136#[cfg(test)]
137#[allow(clippy::expect_used, clippy::unwrap_used)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn detect_from_byte_known_bytes() {
143        assert_eq!(
144            ZonedEncodingFormat::detect_from_byte(0x35),
145            Some(ZonedEncodingFormat::Ascii)
146        );
147        assert_eq!(
148            ZonedEncodingFormat::detect_from_byte(0xF4),
149            Some(ZonedEncodingFormat::Ebcdic)
150        );
151        assert_eq!(ZonedEncodingFormat::detect_from_byte(0x00), None);
152    }
153
154    #[test]
155    fn display_and_predicates() {
156        assert!(ZonedEncodingFormat::Ascii.is_ascii());
157        assert!(!ZonedEncodingFormat::Auto.is_ascii());
158        assert_eq!(format!("{}", ZonedEncodingFormat::Ascii), "ascii");
159        assert_eq!(
160            ZonedEncodingFormat::Auto.description(),
161            "Automatic detection based on zone nibbles"
162        );
163    }
164
165    // --- is_ebcdic ---
166
167    #[test]
168    fn test_is_ebcdic() {
169        assert!(ZonedEncodingFormat::Ebcdic.is_ebcdic());
170        assert!(!ZonedEncodingFormat::Ascii.is_ebcdic());
171        assert!(!ZonedEncodingFormat::Auto.is_ebcdic());
172    }
173
174    // --- is_auto ---
175
176    #[test]
177    fn test_is_auto() {
178        assert!(ZonedEncodingFormat::Auto.is_auto());
179        assert!(!ZonedEncodingFormat::Ascii.is_auto());
180        assert!(!ZonedEncodingFormat::Ebcdic.is_auto());
181    }
182
183    // --- Default ---
184
185    #[test]
186    fn test_default_is_auto() {
187        assert_eq!(ZonedEncodingFormat::default(), ZonedEncodingFormat::Auto);
188    }
189
190    // --- Display all variants ---
191
192    #[test]
193    fn test_display_all_variants() {
194        assert_eq!(format!("{}", ZonedEncodingFormat::Ascii), "ascii");
195        assert_eq!(format!("{}", ZonedEncodingFormat::Ebcdic), "ebcdic");
196        assert_eq!(format!("{}", ZonedEncodingFormat::Auto), "auto");
197    }
198
199    // --- description all variants ---
200
201    #[test]
202    fn test_description_all_variants() {
203        assert_eq!(
204            ZonedEncodingFormat::Ascii.description(),
205            "ASCII digit zones (0x30-0x39)"
206        );
207        assert_eq!(
208            ZonedEncodingFormat::Ebcdic.description(),
209            "EBCDIC digit zones (0xF0-0xF9)"
210        );
211        assert_eq!(
212            ZonedEncodingFormat::Auto.description(),
213            "Automatic detection based on zone nibbles"
214        );
215    }
216
217    // --- detect_from_byte comprehensive ---
218
219    #[test]
220    fn test_detect_from_byte_all_ascii_digits() {
221        for byte in 0x30..=0x3F {
222            assert_eq!(
223                ZonedEncodingFormat::detect_from_byte(byte),
224                Some(ZonedEncodingFormat::Ascii),
225                "Failed for byte 0x{byte:02X}"
226            );
227        }
228    }
229
230    #[test]
231    fn test_detect_from_byte_all_ebcdic_digits() {
232        for byte in 0xF0..=0xFF {
233            assert_eq!(
234                ZonedEncodingFormat::detect_from_byte(byte),
235                Some(ZonedEncodingFormat::Ebcdic),
236                "Failed for byte 0x{byte:02X}"
237            );
238        }
239    }
240
241    #[test]
242    fn test_detect_from_byte_invalid_zones() {
243        // Zone nibbles 0x0, 0x1, 0x2, 0x4-0xE should return None
244        let invalid_samples: &[u8] = &[
245            0x00, 0x10, 0x20, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0,
246        ];
247        for &byte in invalid_samples {
248            assert_eq!(
249                ZonedEncodingFormat::detect_from_byte(byte),
250                None,
251                "Expected None for byte 0x{byte:02X}"
252            );
253        }
254    }
255
256    // --- Serde round-trip ---
257
258    #[test]
259    fn test_serde_roundtrip() {
260        for variant in [
261            ZonedEncodingFormat::Ascii,
262            ZonedEncodingFormat::Ebcdic,
263            ZonedEncodingFormat::Auto,
264        ] {
265            let json = serde_json::to_string(&variant).unwrap();
266            let deserialized: ZonedEncodingFormat = serde_json::from_str(&json).unwrap();
267            assert_eq!(
268                variant, deserialized,
269                "Serde round-trip failed for {variant:?}"
270            );
271        }
272    }
273
274    // --- Clone / Copy / Eq ---
275
276    #[test]
277    fn test_clone_and_eq() {
278        let a = ZonedEncodingFormat::Ebcdic;
279        let b = a;
280        assert_eq!(a, b);
281    }
282}