copybook_codec/numeric.rs
1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! # Numeric Type Codecs for COBOL Data
3//!
4//! This module provides encoding and decoding functions for the three main COBOL numeric
5//! data types:
6//!
7//! - **Zoned Decimal** (`PIC 9` with optional `SIGN SEPARATE`): External decimal format
8//! where each digit is stored in a byte with a zone nibble and a digit nibble.
9//! The sign may be encoded via overpunch or stored in a separate byte.
10//!
11//! - **Packed Decimal** (`COMP-3`): Compact binary format where each byte contains
12//! two decimal digits (nibbles), with the last nibble containing the sign.
13//!
14//! - **Binary Integer** (`COMP-4`, `COMP-5`, `BINARY`): Standard binary integer
15//! encoding in big-endian byte order.
16//!
17//! ## Module Organization
18//!
19//! The module is organized into three main categories:
20//!
21//! ### Decoding Functions
22//! - [`decode_zoned_decimal`](crate::numeric::decode_zoned_decimal) - Decode zoned decimal fields
23//! - [`decode_zoned_decimal_sign_separate`](crate::numeric::decode_zoned_decimal_sign_separate) - Decode SIGN SEPARATE zoned decimals
24//! - [`decode_zoned_decimal_with_encoding`](crate::numeric::decode_zoned_decimal_with_encoding) - Decode with encoding detection
25//! - [`decode_packed_decimal`](crate::numeric::decode_packed_decimal) - Decode COMP-3 packed decimals
26//! - [`decode_binary_int`](crate::numeric::decode_binary_int) - Decode binary integer fields
27//!
28//! ### Encoding Functions
29//! - [`encode_zoned_decimal`](crate::numeric::encode_zoned_decimal) - Encode zoned decimal fields
30//! - [`encode_zoned_decimal_with_format`](crate::numeric::encode_zoned_decimal_with_format) - Encode with explicit encoding format
31//! - [`encode_zoned_decimal_with_format_and_policy`](crate::numeric::encode_zoned_decimal_with_format_and_policy) - Encode with format and policy
32//! - [`encode_zoned_decimal_with_bwz`](crate::numeric::encode_zoned_decimal_with_bwz) - Encode with BLANK WHEN ZERO support
33//! - [`encode_packed_decimal`](crate::numeric::encode_packed_decimal) - Encode COMP-3 packed decimals
34//! - [`encode_binary_int`](crate::numeric::encode_binary_int) - Encode binary integer fields
35//!
36//! ### Utility Functions
37//! - [`get_binary_width_from_digits`](crate::numeric::get_binary_width_from_digits) - Map digit count to binary width
38//! - [`validate_explicit_binary_width`](crate::numeric::validate_explicit_binary_width) - Validate explicit BINARY(n) widths
39//! - [`should_encode_as_blank_when_zero`](crate::numeric::should_encode_as_blank_when_zero) - Check BLANK WHEN ZERO policy
40//!
41//! ## Performance Considerations
42//!
43//! This module is optimized for high-throughput enterprise data processing:
44//!
45//! - **Hot path optimization**: Common cases (1-5 byte COMP-3, ASCII zoned) use
46//! specialized fast paths
47//! - **Branch prediction**: Manual hints mark error paths as unlikely
48//! - **Zero-allocation**: Scratch buffer variants avoid repeated allocations in loops
49//! - **Saturating arithmetic**: Prevents panics while maintaining correctness
50//!
51//! ## Encoding Formats
52//!
53//! ### Zoned Decimal Encoding
54//!
55//! Zoned decimals support two primary encoding formats:
56//!
57//! | Format | Zone Nibble | Example Digits | Sign Encoding |
58//! |---------|--------------|----------------|---------------|
59//! | ASCII | `0x3` | `0x30`-`0x39` | Overpunch or separate |
60//! | EBCDIC | `0xF` | `0xF0`-`0xF9` | Overpunch or separate |
61//!
62//! ### Packed Decimal Sign Nibbles
63//!
64//! COMP-3 uses the last nibble for sign encoding:
65//!
66//! | Sign | Nibble | Description |
67//! |------|---------|-------------|
68//! | Positive | `0xC`, `0xA`, `0xE`, `0xF` | Positive values |
69//! | Negative | `0xB`, `0xD` | Negative values |
70//! | Unsigned | `0xF` | Unsigned fields only |
71//!
72//! ### Binary Integer Widths
73//!
74//! Binary integers use the following width mappings:
75//!
76//! | Digits | Width | Bits | Range (signed) |
77//! |---------|--------|-------|----------------|
78//! | 1-4 | 2 bytes | 16 | -32,768 to 32,767 |
79//! | 5-9 | 4 bytes | 32 | -2,147,483,648 to 2,147,483,647 |
80//! | 10-18 | 8 bytes | 64 | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
81//!
82//! ## Examples
83//!
84//! ### Decoding a Zoned Decimal
85//!
86//! ```no_run
87//! use copybook_codec::numeric::{decode_zoned_decimal};
88//! use copybook_codec::options::Codepage;
89//!
90//! // ASCII zoned decimal: "123" = [0x31, 0x32, 0x33]
91//! let data = b"123";
92//! let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
93//! assert_eq!(result.to_string(), "123");
94//! # Ok::<(), copybook_core::Error>(())
95//! ```
96//!
97//! ### Encoding a Packed Decimal
98//!
99//! ```no_run
100//! use copybook_codec::numeric::{encode_packed_decimal};
101//!
102//! // Encode "123.45" as 7-digit COMP-3 with 2 decimal places
103//! let encoded = encode_packed_decimal("123.45", 7, 2, true)?;
104//! // Result: [0x12, 0x34, 0x5C] (12345 positive)
105//! # Ok::<(), copybook_core::Error>(())
106//! ```
107//!
108//! ## See Also
109//!
110//! - [`crate::zoned_overpunch`] - Zoned decimal overpunch encoding/decoding
111//! - [`crate::SmallDecimal`] - Decimal representation without floating-point precision loss
112//! - [`crate::memory::ScratchBuffers`] - Reusable buffers for zero-allocation processing
113
114use crate::memory::ScratchBuffers;
115use crate::options::{Codepage, FloatFormat, ZonedEncodingFormat};
116use crate::zoned_overpunch::{ZeroSignPolicy, encode_overpunch_byte};
117use copybook_core::{Error, ErrorCode, Result, SignPlacement, SignSeparateInfo};
118use std::convert::TryFrom;
119use std::fmt::Write;
120use tracing::warn;
121
122/// Nibble zones for ASCII/EBCDIC digits (high bits in zoned bytes).
123const ASCII_DIGIT_ZONE: u8 = 0x3; // ASCII '0'..'9' => 0x30..0x39
124const EBCDIC_DIGIT_ZONE: u8 = 0xF; // EBCDIC '0'..'9' => 0xF0..0xF9
125
126/// Branch prediction hint for likely-true conditions
127///
128/// Provides a manual branch prediction hint to the compiler that the condition
129/// is likely to be true. This optimization helps keep hot paths efficient by
130/// marking the false case as cold.
131///
132/// # Arguments
133/// * `b` - Boolean condition to evaluate
134///
135/// # Returns
136/// The input boolean value unchanged
137///
138/// # Performance
139/// This function is critical for COBOL data decoding hot paths where valid
140/// data is the common case and errors are exceptional.
141///
142/// # Examples
143/// ```text
144/// use copybook_codec::numeric::likely;
145///
146/// let valid_data = true;
147/// if likely(valid_data) {
148/// // Hot path - optimized for this case
149/// }
150/// ```
151#[inline]
152pub(crate) fn likely(b: bool) -> bool {
153 // CRITICAL PERFORMANCE OPTIMIZATION: Manual branch prediction optimization
154 // The true case is expected to be taken most of the time (likely path)
155 // Mark the false case as cold to optimize for the common true case
156 if b {
157 true
158 } else {
159 cold_branch_hint();
160 false
161 }
162}
163
164/// Branch prediction hint for unlikely-true conditions
165///
166/// Provides a manual branch prediction hint to the compiler that the condition
167/// is unlikely to be true. This optimization keeps error paths cold and hot
168/// paths optimized.
169///
170/// # Arguments
171/// * `b` - Boolean condition to evaluate
172///
173/// # Returns
174/// The input boolean value unchanged
175///
176/// # Performance
177/// Critical for error handling in COBOL numeric decoding where validation
178/// failures are exceptional cases.
179///
180/// # Examples
181/// ```text
182/// use copybook_codec::numeric::unlikely;
183///
184/// let error_condition = false;
185/// if unlikely(error_condition) {
186/// // Cold path - marked as unlikely
187/// }
188/// ```
189#[inline]
190pub(crate) fn unlikely(b: bool) -> bool {
191 // CRITICAL PERFORMANCE OPTIMIZATION: Manual branch prediction optimization
192 // Use explicit cold annotation to hint that error paths are unlikely
193 // This provides significant speedup by keeping hot paths optimized
194 if b {
195 // Cold path: mark as unlikely taken
196 cold_branch_hint();
197 true
198 } else {
199 false
200 }
201}
202
203/// Manual branch prediction hint for cold paths
204///
205/// This function serves as a branch prediction hint that the calling path is cold/unlikely.
206/// The `#[cold]` attribute tells the compiler this is an unlikely execution path, and
207/// `#[inline(never)]` ensures the cold path doesn't bloat the hot path.
208#[cold]
209#[inline(never)]
210fn cold_branch_hint() {
211 // This function serves as a branch prediction hint that the calling path is cold/unlikely
212 // The #[cold] attribute tells the compiler this is an unlikely execution path
213}
214
215/// Create a normalized `SmallDecimal` from raw components
216///
217/// Constructs a `SmallDecimal` from value, scale, and sign components, then
218/// normalizes it to ensure -0 becomes 0.
219///
220/// # Arguments
221/// * `value` - The unscaled integer value
222/// * `scale` - Number of decimal places (positive for fractions, 0 for integers)
223/// * `is_negative` - Whether the value is negative
224///
225/// # Returns
226/// A normalized `SmallDecimal` instance
227///
228/// # Performance
229/// This inline function is optimized for hot paths in packed decimal decoding
230/// where decimals are frequently constructed from parsed nibbles.
231///
232/// # Examples
233/// ```
234/// use copybook_codec::numeric::SmallDecimal;
235///
236/// // Create 123.45 (value=12345, scale=2)
237/// let decimal = SmallDecimal::new(12345, 2, false);
238/// assert_eq!(decimal.to_string(), "123.45");
239/// ```
240#[inline]
241fn create_normalized_decimal(value: i64, scale: i16, is_negative: bool) -> SmallDecimal {
242 let mut decimal = SmallDecimal::new(value, scale, is_negative);
243 decimal.normalize();
244 decimal
245}
246
247/// Statistics collector for encoding analysis
248///
249/// Tracks the distribution of encoding formats within a zoned decimal field
250/// to determine the overall format and detect mixed encoding patterns.
251#[derive(Debug, Default)]
252#[allow(clippy::struct_field_names)] // Fields are descriptive counters
253struct EncodingAnalysisStats {
254 ascii_count: usize,
255 ebcdic_count: usize,
256 invalid_count: usize,
257}
258
259impl EncodingAnalysisStats {
260 /// Create a new statistics collector
261 fn new() -> Self {
262 Self::default()
263 }
264
265 /// Record a detected format in the statistics
266 fn record_format(&mut self, format: Option<ZonedEncodingFormat>) {
267 match format {
268 Some(ZonedEncodingFormat::Ascii) => self.ascii_count += 1,
269 Some(ZonedEncodingFormat::Ebcdic) => self.ebcdic_count += 1,
270 Some(ZonedEncodingFormat::Auto) => { /* Should not occur in detection */ }
271 None => self.invalid_count += 1,
272 }
273 }
274
275 /// Determine the overall format and mixed encoding status from collected statistics
276 ///
277 /// # Logic
278 /// - If invalid zones exist: Auto format with mixed encoding flag
279 /// - If both ASCII and EBCDIC zones exist: Auto format with mixed encoding flag
280 /// - If only ASCII zones: ASCII format, no mixing
281 /// - If only EBCDIC zones: EBCDIC format, no mixing
282 /// - If no valid zones: Auto format, no mixing (empty or all invalid)
283 fn determine_overall_format(&self) -> (ZonedEncodingFormat, bool) {
284 if self.invalid_count > 0 {
285 // Invalid zones detected - cannot determine format reliably
286 (ZonedEncodingFormat::Auto, true)
287 } else if self.ascii_count > 0 && self.ebcdic_count > 0 {
288 // Mixed ASCII and EBCDIC zones within the same field
289 (ZonedEncodingFormat::Auto, true)
290 } else if self.ascii_count > 0 {
291 // Consistent ASCII encoding throughout the field
292 (ZonedEncodingFormat::Ascii, false)
293 } else if self.ebcdic_count > 0 {
294 // Consistent EBCDIC encoding throughout the field
295 (ZonedEncodingFormat::Ebcdic, false)
296 } else {
297 // No valid zones found (empty field or all invalid)
298 (ZonedEncodingFormat::Auto, false)
299 }
300 }
301}
302
303/// Comprehensive encoding detection result for zoned decimal fields
304///
305/// Provides detailed analysis of zoned decimal encoding patterns within a field,
306/// enabling detection of mixed encodings and validation of data consistency.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct ZonedEncodingInfo {
309 /// Overall detected encoding format for the field
310 pub detected_format: ZonedEncodingFormat,
311 /// True if mixed ASCII/EBCDIC encoding was detected within the field
312 pub has_mixed_encoding: bool,
313 /// Per-byte encoding detection results for detailed analysis
314 pub byte_formats: Vec<Option<ZonedEncodingFormat>>,
315}
316
317impl ZonedEncodingInfo {
318 /// Create new encoding info with the specified format and mixed encoding status
319 ///
320 /// # Arguments
321 /// * `detected_format` - The overall encoding format determined for the field
322 /// * `has_mixed_encoding` - Whether mixed encoding patterns were detected
323 #[inline]
324 #[must_use]
325 pub fn new(detected_format: ZonedEncodingFormat, has_mixed_encoding: bool) -> Self {
326 Self {
327 detected_format,
328 has_mixed_encoding,
329 byte_formats: Vec::new(),
330 }
331 }
332
333 /// Analyze zoned decimal data bytes to detect encoding patterns
334 ///
335 /// Analyze bytes to identify the zoned encoding mix for downstream encode.
336 ///
337 /// # Errors
338 /// Returns an error if detection fails (currently never fails).
339 #[inline]
340 #[must_use = "Handle the Result or propagate the error"]
341 pub fn detect_from_data(data: &[u8]) -> Result<Self> {
342 if data.is_empty() {
343 return Ok(Self::new(ZonedEncodingFormat::Auto, false));
344 }
345
346 let mut byte_formats = Vec::with_capacity(data.len());
347 let mut encoding_stats = EncodingAnalysisStats::new();
348
349 // Analyze each byte's zone nibble for encoding patterns
350 for &byte in data {
351 let format = Self::analyze_zone_nibble(byte);
352 byte_formats.push(format);
353 encoding_stats.record_format(format);
354 }
355
356 // Determine overall encoding and mixed status from statistics
357 let (detected_format, has_mixed_encoding) = encoding_stats.determine_overall_format();
358
359 Ok(Self {
360 detected_format,
361 has_mixed_encoding,
362 byte_formats,
363 })
364 }
365
366 /// Analyze a single byte's zone nibble to determine its encoding format
367 ///
368 /// # Zone Nibble Analysis
369 /// - `0x3`: ASCII digit zone (0x30-0x39 range)
370 /// - `0xF`: EBCDIC digit zone (0xF0-0xF9 range)
371 /// - ASCII overpunch characters: 0x4X, 0x7B, 0x7D (sign encoding)
372 /// - Others: Invalid or non-standard zones
373 fn analyze_zone_nibble(byte: u8) -> Option<ZonedEncodingFormat> {
374 const ASCII_ZONE: u8 = 0x3;
375 const EBCDIC_ZONE: u8 = 0xF;
376 const ZONE_MASK: u8 = 0x0F;
377
378 // Check for specific ASCII overpunch characters first
379 match byte {
380 // ASCII overpunch sign bytes and overpunch characters (A-I, J-R)
381 0x7B | 0x7D | 0x41..=0x52 => return Some(ZonedEncodingFormat::Ascii),
382 _ => {}
383 }
384
385 let zone_nibble = (byte >> 4) & ZONE_MASK;
386 match zone_nibble {
387 ASCII_ZONE => Some(ZonedEncodingFormat::Ascii),
388 EBCDIC_ZONE | 0xC | 0xD => Some(ZonedEncodingFormat::Ebcdic),
389 _ => None, // Invalid or mixed zone
390 }
391 }
392}
393
394/// Small decimal structure for parsing/formatting without floats
395/// This avoids floating-point precision issues for financial data
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct SmallDecimal {
398 /// The integer value (unscaled)
399 pub value: i64,
400 /// The scale (number of decimal places)
401 pub scale: i16,
402 /// Whether the value is negative
403 pub negative: bool,
404}
405
406impl SmallDecimal {
407 /// Create a new `SmallDecimal`.
408 #[inline]
409 #[must_use]
410 pub fn new(value: i64, scale: i16, negative: bool) -> Self {
411 Self {
412 value,
413 scale,
414 negative,
415 }
416 }
417
418 /// Create a zero value with the given scale
419 #[inline]
420 #[must_use]
421 pub fn zero(scale: i16) -> Self {
422 Self {
423 value: 0,
424 scale,
425 negative: false,
426 }
427 }
428
429 /// Normalize -0 to 0 (NORMATIVE)
430 #[inline]
431 pub fn normalize(&mut self) {
432 if self.value == 0 {
433 self.negative = false;
434 }
435 }
436
437 /// Format as string with fixed scale (NORMATIVE)
438 ///
439 /// Always render with exactly `scale` digits after decimal point.
440 #[allow(clippy::inherent_to_string)] // Intentional - this is a specific numeric formatting
441 #[inline]
442 #[must_use = "Use the formatted string output"]
443 pub fn to_string(&self) -> String {
444 let mut result = String::new();
445 self.append_sign_if_negative(&mut result);
446 self.append_formatted_value(&mut result);
447 result
448 }
449
450 /// Check if this decimal represents a zero value
451 fn is_zero_value(&self) -> bool {
452 self.value == 0
453 }
454
455 /// Append negative sign to the result if the value is negative and non-zero
456 fn append_sign_if_negative(&self, result: &mut String) {
457 if self.negative && !self.is_zero_value() {
458 result.push('-');
459 }
460 }
461
462 /// Append the formatted numeric value (integer or decimal) to the result
463 fn append_formatted_value(&self, result: &mut String) {
464 if self.scale <= 0 {
465 self.append_integer_format(result);
466 } else {
467 self.append_decimal_format(result);
468 }
469 }
470
471 /// Append integer format or scale extension to the result
472 fn append_integer_format(&self, result: &mut String) {
473 let scaled_value = if self.scale < 0 {
474 // Scale extension: multiply by 10^(-scale)
475 self.value * 10_i64.pow(scale_abs_to_u32(self.scale))
476 } else {
477 // Normal integer format (scale = 0)
478 self.value
479 };
480 // Writing to String should never fail, but handle gracefully for panic elimination
481 if write!(result, "{scaled_value}").is_err() {
482 // Fallback: append a placeholder if formatting somehow fails
483 result.push_str("ERR");
484 }
485 }
486
487 /// Append decimal format with exactly `scale` digits after decimal point
488 fn append_decimal_format(&self, result: &mut String) {
489 let divisor = 10_i64.pow(scale_abs_to_u32(self.scale));
490 let integer_part = self.value / divisor;
491 let fractional_part = self.value % divisor;
492
493 // Writing to String should never fail, but handle gracefully for panic elimination
494 let width = usize::try_from(self.scale).unwrap_or_else(|_| {
495 debug_assert!(false, "scale should be positive when formatting decimal");
496 0
497 });
498
499 if write!(result, "{integer_part}.{fractional_part:0width$}").is_err() {
500 // Fallback: append a placeholder if formatting somehow fails
501 result.push_str("ERR");
502 }
503 }
504
505 /// Parse a decimal string into a `SmallDecimal` using the expected scale.
506 ///
507 /// # Errors
508 /// Returns an error when the text violates the expected numeric format.
509 #[inline]
510 #[must_use = "Handle the Result or propagate the error"]
511 pub fn from_str(s: &str, expected_scale: i16) -> Result<Self> {
512 let trimmed = s.trim();
513 if trimmed.is_empty() {
514 return Ok(Self::zero(expected_scale));
515 }
516
517 let (negative, numeric_part) = Self::extract_sign(trimmed);
518
519 if let Some(dot_pos) = numeric_part.find('.') {
520 Self::parse_decimal_format(numeric_part, dot_pos, expected_scale, negative)
521 } else {
522 Self::parse_integer_format(numeric_part, expected_scale, negative)
523 }
524 }
525
526 /// Extract sign information from the numeric string
527 ///
528 /// Returns (`is_negative`, `numeric_part_without_sign`).
529 fn extract_sign(s: &str) -> (bool, &str) {
530 if let Some(without_minus) = s.strip_prefix('-') {
531 (true, without_minus)
532 } else {
533 (false, s)
534 }
535 }
536
537 /// Parse decimal format string (contains decimal point)
538 fn parse_decimal_format(
539 numeric_part: &str,
540 dot_pos: usize,
541 expected_scale: i16,
542 negative: bool,
543 ) -> Result<Self> {
544 let integer_part = &numeric_part[..dot_pos];
545 let fractional_part = &numeric_part[dot_pos + 1..];
546
547 // Validate scale matches exactly (NORMATIVE)
548 let expected_len = usize::try_from(expected_scale).map_err(|_| {
549 Error::new(
550 ErrorCode::CBKE505_SCALE_MISMATCH,
551 format!(
552 "Scale mismatch: expected {expected_scale} decimal places, got {}",
553 fractional_part.len()
554 ),
555 )
556 })?;
557
558 if fractional_part.len() != expected_len {
559 return Err(Error::new(
560 ErrorCode::CBKE505_SCALE_MISMATCH,
561 format!(
562 "Scale mismatch: expected {expected_scale} decimal places, got {}",
563 fractional_part.len()
564 ),
565 ));
566 }
567
568 let integer_value = Self::parse_integer_component(integer_part)?;
569 let fractional_value = Self::parse_integer_component(fractional_part)?;
570 let total_value =
571 Self::combine_integer_and_fractional(integer_value, fractional_value, expected_scale)?;
572
573 let mut result = Self::new(total_value, expected_scale, negative);
574 result.normalize();
575 Ok(result)
576 }
577
578 /// Parse integer format string (no decimal point)
579 fn parse_integer_format(
580 numeric_part: &str,
581 expected_scale: i16,
582 negative: bool,
583 ) -> Result<Self> {
584 // Defense-in-depth: accept bare "0" for scaled fields (e.g., user-provided JSON)
585 if expected_scale != 0 {
586 let value = Self::parse_integer_component(numeric_part)?;
587 if value == 0 {
588 let mut result = Self::new(0, expected_scale, negative);
589 result.normalize();
590 return Ok(result);
591 }
592 return Err(Error::new(
593 ErrorCode::CBKE505_SCALE_MISMATCH,
594 format!("Scale mismatch: expected {expected_scale} decimal places, got integer"),
595 ));
596 }
597
598 let value = Self::parse_integer_component(numeric_part)?;
599 let mut result = Self::new(value, expected_scale, negative);
600 result.normalize();
601 Ok(result)
602 }
603
604 /// Parse a string component as an integer with error handling
605 fn parse_integer_component(s: &str) -> Result<i64> {
606 s.parse().map_err(|_| {
607 Error::new(
608 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
609 format!("Invalid numeric component: '{s}'"),
610 )
611 })
612 }
613
614 /// Combine integer and fractional parts into a scaled value
615 fn combine_integer_and_fractional(
616 integer_value: i64,
617 fractional_value: i64,
618 scale: i16,
619 ) -> Result<i64> {
620 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
621 integer_value
622 .checked_mul(divisor)
623 .and_then(|v| v.checked_add(fractional_value))
624 .ok_or_else(|| {
625 Error::new(
626 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
627 "Numeric value too large - would cause overflow",
628 )
629 })
630 }
631
632 /// Format as string with the given fixed scale.
633 ///
634 /// Always renders with exactly `scale` digits after the decimal point,
635 /// independent of the `SmallDecimal`'s own scale. Used for lossless JSON output.
636 #[inline]
637 #[must_use]
638 pub fn to_fixed_scale_string(&self, scale: i16) -> String {
639 let mut result = String::new();
640
641 if self.negative && self.value != 0 {
642 result.push('-');
643 }
644
645 if scale <= 0 {
646 // Integer format (scale=0) or scale extension
647 let scaled_value = if scale < 0 {
648 self.value * 10_i64.pow(scale_abs_to_u32(scale))
649 } else {
650 self.value
651 };
652 // Writing to String should never fail, but handle gracefully for panic elimination
653 if write!(result, "{scaled_value}").is_err() {
654 result.push_str("ERR");
655 }
656 } else {
657 // Decimal format with exactly `scale` digits after decimal
658 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
659 let integer_part = self.value / divisor;
660 let fractional_part = self.value % divisor;
661
662 // Writing to String should never fail, but handle gracefully for panic elimination
663 let width = usize::try_from(scale).unwrap_or_else(|_| {
664 debug_assert!(false, "scale should be positive in decimal formatting");
665 0
666 });
667
668 if write!(result, "{integer_part}.{fractional_part:0width$}").is_err() {
669 result.push_str("ERR");
670 }
671 }
672
673 result
674 }
675
676 /// Format the decimal into a caller-owned string buffer to avoid allocation.
677 ///
678 /// This is the hot-path formatter used in COMP-3 JSON conversion. The buffer
679 /// is cleared before writing.
680 #[inline]
681 pub fn format_to_scratch_buffer(&self, scale: i16, scratch_buffer: &mut String) {
682 scratch_buffer.clear();
683
684 if self.negative && self.value != 0 {
685 scratch_buffer.push('-');
686 }
687
688 if scale <= 0 {
689 // Integer format (scale=0) or scale extension
690 let scaled_value = if scale < 0 {
691 self.value * 10_i64.pow(scale_abs_to_u32(scale))
692 } else {
693 self.value
694 };
695 // CRITICAL OPTIMIZATION: Manual integer formatting to avoid write!() overhead
696 Self::format_integer_manual(scaled_value, scratch_buffer);
697 } else {
698 // Decimal format with exactly `scale` digits after decimal
699 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
700 let integer_part = self.value / divisor;
701 let fractional_part = self.value % divisor;
702
703 // CRITICAL OPTIMIZATION: Manual decimal formatting to avoid write!() overhead
704 Self::format_integer_manual(integer_part, scratch_buffer);
705 scratch_buffer.push('.');
706 Self::format_integer_with_leading_zeros(
707 fractional_part,
708 scale_abs_to_u32(scale),
709 scratch_buffer,
710 );
711 }
712 }
713
714 /// Ultra-fast manual integer formatting to avoid write!() macro overhead
715 #[inline]
716 fn format_integer_manual(mut value: i64, buffer: &mut String) {
717 if value == 0 {
718 buffer.push('0');
719 return;
720 }
721
722 // Optimized formatting with fewer divisions for common cases
723 if value < 100 {
724 // Fast path for 1-2 digit numbers (very common in COMP-3)
725 if value < 10 {
726 push_digit(buffer, value);
727 } else {
728 let tens = value / 10;
729 let ones = value % 10;
730 push_digit(buffer, tens);
731 push_digit(buffer, ones);
732 }
733 return;
734 }
735
736 // Use a small stack buffer for digits for larger numbers
737 let mut digits = [0u8; 20]; // More than enough for i64::MAX
738 let mut count = 0;
739
740 // Safe: i64::MAX has 19 digits, array has 20 elements
741 while value > 0 && count < 20 {
742 digits[count] = digit_from_value(value % 10);
743 value /= 10;
744 count += 1;
745 }
746
747 // Add digits in reverse order
748 for i in (0..count).rev() {
749 buffer.push(char::from(b'0' + digits[i]));
750 }
751 }
752
753 /// Ultra-fast manual integer formatting with leading zeros
754 #[inline]
755 fn format_integer_with_leading_zeros(mut value: i64, width: u32, buffer: &mut String) {
756 // Optimized for common small widths (most COMP-3 scales are 0-4)
757 if width <= 4 && value < 10000 {
758 match width {
759 1 => {
760 push_digit(buffer, value);
761 }
762 2 => {
763 push_digit(buffer, value / 10);
764 push_digit(buffer, value % 10);
765 }
766 3 => {
767 push_digit(buffer, value / 100);
768 push_digit(buffer, (value / 10) % 10);
769 push_digit(buffer, value % 10);
770 }
771 4 => {
772 push_digit(buffer, value / 1000);
773 push_digit(buffer, (value / 100) % 10);
774 push_digit(buffer, (value / 10) % 10);
775 push_digit(buffer, value % 10);
776 }
777 _ => {}
778 }
779 return;
780 }
781
782 // General case for larger widths
783 let mut digits = [0u8; 20]; // More than enough for i64::MAX
784 let mut count = 0;
785 // Clamp target_width to array size to prevent out-of-bounds access
786 let target_width = usize::try_from(width).unwrap_or(usize::MAX).min(20);
787
788 // Extract digits (safe: i64::MAX has at most 19 digits, array has 20 elements)
789 loop {
790 digits[count] = digit_from_value(value % 10);
791 value /= 10;
792 count += 1;
793 // Safety: count is bounded by min(19, target_width) where target_width <= 20
794 if value == 0 && count >= target_width {
795 break;
796 }
797 if count >= 20 {
798 // Defensive: should never happen for i64, but prevents any overflow
799 break;
800 }
801 }
802
803 // Pad with leading zeros if needed (count is guaranteed <= 20)
804 while count < target_width {
805 digits[count] = 0;
806 count += 1;
807 }
808
809 // Add digits in reverse order
810 for i in (0..count).rev() {
811 buffer.push(char::from(b'0' + digits[i]));
812 }
813 }
814
815 /// Get the scale of this decimal
816 #[inline]
817 #[must_use]
818 pub fn scale(&self) -> i16 {
819 self.scale
820 }
821
822 /// Check if this decimal is negative
823 #[inline]
824 #[must_use]
825 pub fn is_negative(&self) -> bool {
826 self.negative && self.value != 0
827 }
828
829 /// Get the total number of digits in this decimal
830 #[inline]
831 #[must_use]
832 pub fn total_digits(&self) -> u16 {
833 if self.value == 0 {
834 return 1;
835 }
836
837 let mut count = 0;
838 let mut val = self.value.abs();
839 while val > 0 {
840 count += 1;
841 val /= 10;
842 }
843 count
844 }
845}
846
847/// Convert an integer value to a single digit (0-9)
848///
849/// Validates that the value is in the range 0-9 and converts it to a u8 digit.
850/// Returns 0 for invalid inputs (with debug assertion).
851///
852/// # Arguments
853/// * `value` - Integer value to convert (expected to be 0-9)
854///
855/// # Returns
856/// A single digit as u8, or 0 if the value is out of range
857///
858/// # Safety
859/// This function includes a debug assertion that fires if the value is out of
860/// range. In release builds, invalid values return 0.
861///
862/// # Performance
863/// Used in hot paths for packed decimal encoding where digits are extracted
864/// from numeric values.
865#[inline]
866fn digit_from_value(value: i64) -> u8 {
867 match u8::try_from(value) {
868 Ok(digit) if digit <= 9 => digit,
869 _ => {
870 debug_assert!(false, "digit out of range: {value}");
871 0
872 }
873 }
874}
875
876/// Push a single digit character to a string buffer
877///
878/// Converts an integer digit (0-9) to its ASCII character representation and
879/// appends it to the buffer.
880///
881/// # Arguments
882/// * `buffer` - String buffer to append to
883/// * `digit` - Integer digit value (0-9)
884///
885/// # Performance
886/// Inline function optimized for decimal formatting hot paths in COMP-3 decoding.
887#[inline]
888fn push_digit(buffer: &mut String, digit: i64) {
889 buffer.push(char::from(b'0' + digit_from_value(digit)));
890}
891
892/// Convert absolute scale value to u32 for power calculations
893///
894/// Takes the absolute value of a scale (i16) and converts it to u32 for use
895/// in power-of-10 calculations.
896///
897/// # Arguments
898/// * `scale` - Scale value (can be negative for integer extensions)
899///
900/// # Returns
901/// Absolute value of scale as u32
902///
903/// # Examples
904/// ```
905/// # fn scale_abs_to_u32(scale: i16) -> u32 { u32::from(scale.unsigned_abs()) }
906/// assert_eq!(scale_abs_to_u32(2), 2);
907/// assert_eq!(scale_abs_to_u32(-2), 2);
908/// assert_eq!(scale_abs_to_u32(0), 0);
909/// ```
910#[inline]
911fn scale_abs_to_u32(scale: i16) -> u32 {
912 u32::from(scale.unsigned_abs())
913}
914
915/// Decode a zoned decimal using the configured code page with detailed error context.
916///
917/// Decodes zoned decimal (PIC 9) fields where each digit is stored in a byte
918/// with a zone nibble and a digit nibble. The sign may be encoded via overpunch
919/// in the last byte's zone nibble.
920///
921/// # Arguments
922/// * `data` - Raw byte data containing the zoned decimal
923/// * `digits` - Number of digit characters (field length)
924/// * `scale` - Number of decimal places (can be negative for scaling)
925/// * `signed` - Whether the field is signed (true) or unsigned (false)
926/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
927/// * `blank_when_zero` - If true, all-space fields decode as zero
928///
929/// # Returns
930/// A `SmallDecimal` containing the decoded value
931///
932/// # Policy
933/// Applies the codec default: ASCII uses `ZeroSignPolicy::Positive`; EBCDIC zeros normalize via `ZeroSignPolicy::Preferred`.
934///
935/// # Errors
936/// Returns an error if the zoned decimal data is invalid or contains bad sign zones.
937/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
938///
939/// # Examples
940///
941/// ## ASCII Zoned Decimal
942///
943/// ```no_run
944/// use copybook_codec::numeric::{decode_zoned_decimal};
945/// use copybook_codec::options::Codepage;
946///
947/// // ASCII "123" = [0x31, 0x32, 0x33]
948/// let data = b"123";
949/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
950/// assert_eq!(result.to_string(), "123");
951/// # Ok::<(), copybook_core::Error>(())
952/// ```
953///
954/// ## Signed ASCII Zoned Decimal (Overpunch)
955///
956/// ```no_run
957/// use copybook_codec::numeric::{decode_zoned_decimal};
958/// use copybook_codec::options::Codepage;
959///
960/// // ASCII "-123" with overpunch: [0x31, 0x32, 0x4D] (M = 3 with negative sign)
961/// let data = [0x31, 0x32, 0x4D];
962/// let result = decode_zoned_decimal(&data, 3, 0, true, Codepage::ASCII, false)?;
963/// assert_eq!(result.to_string(), "-123");
964/// # Ok::<(), copybook_core::Error>(())
965/// ```
966///
967/// ## EBCDIC Zoned Decimal
968///
969/// ```no_run
970/// use copybook_codec::numeric::{decode_zoned_decimal};
971/// use copybook_codec::options::Codepage;
972///
973/// // EBCDIC "123" = [0xF1, 0xF2, 0xF3]
974/// let data = [0xF1, 0xF2, 0xF3];
975/// let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, false)?;
976/// assert_eq!(result.to_string(), "123");
977/// # Ok::<(), copybook_core::Error>(())
978/// ```
979///
980/// ## Decimal Scale
981///
982/// ```no_run
983/// use copybook_codec::numeric::{decode_zoned_decimal};
984/// use copybook_codec::options::Codepage;
985///
986/// // "12.34" with 2 decimal places
987/// let data = b"1234";
988/// let result = decode_zoned_decimal(data, 4, 2, false, Codepage::ASCII, false)?;
989/// assert_eq!(result.to_string(), "12.34");
990/// # Ok::<(), copybook_core::Error>(())
991/// ```
992///
993/// ## BLANK WHEN ZERO
994///
995/// ```no_run
996/// use copybook_codec::numeric::{decode_zoned_decimal};
997/// use copybook_codec::options::Codepage;
998///
999/// // All spaces decode as zero when blank_when_zero is true
1000/// let data = b" ";
1001/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, true)?;
1002/// assert_eq!(result.to_string(), "0");
1003/// # Ok::<(), copybook_core::Error>(())
1004/// ```
1005///
1006/// # See Also
1007/// * [`decode_zoned_decimal_sign_separate`] - For SIGN SEPARATE fields
1008/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
1009/// * [`encode_zoned_decimal`] - For encoding zoned decimals
1010#[inline]
1011#[must_use = "Handle the Result or propagate the error"]
1012pub fn decode_zoned_decimal(
1013 data: &[u8],
1014 digits: u16,
1015 scale: i16,
1016 signed: bool,
1017 codepage: Codepage,
1018 blank_when_zero: bool,
1019) -> Result<SmallDecimal> {
1020 if unlikely(data.len() != usize::from(digits)) {
1021 return Err(Error::new(
1022 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1023 "Zoned decimal data length mismatch".to_string(),
1024 ));
1025 }
1026
1027 // Check for BLANK WHEN ZERO (all spaces)
1028 let is_all_spaces = data.iter().all(|&b| {
1029 match codepage {
1030 Codepage::ASCII => b == b' ',
1031 _ => b == 0x40, // EBCDIC space
1032 }
1033 });
1034
1035 if is_all_spaces {
1036 if blank_when_zero {
1037 warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
1038 // Track this warning in RunSummary
1039 crate::lib_api::increment_warning_counter();
1040 return Ok(SmallDecimal::zero(scale));
1041 }
1042 return Err(Error::new(
1043 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1044 "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
1045 ));
1046 }
1047
1048 let mut value = 0i64;
1049 let mut is_negative = false;
1050 let expected_zone = match codepage {
1051 Codepage::ASCII => ASCII_DIGIT_ZONE,
1052 _ => EBCDIC_DIGIT_ZONE,
1053 };
1054
1055 for (i, &byte) in data.iter().enumerate() {
1056 if i == data.len() - 1 {
1057 let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
1058
1059 if signed {
1060 is_negative = negative;
1061 } else {
1062 let zone = (byte >> 4) & 0x0F;
1063 let zone_label = match codepage {
1064 Codepage::ASCII => "ASCII",
1065 _ => "EBCDIC",
1066 };
1067 if zone != expected_zone {
1068 return Err(Error::new(
1069 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1070 format!(
1071 "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
1072 ),
1073 ));
1074 }
1075 if negative {
1076 return Err(Error::new(
1077 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1078 "Unsigned zoned decimal contains negative overpunch",
1079 ));
1080 }
1081 }
1082
1083 value = value.saturating_mul(10).saturating_add(i64::from(digit));
1084 } else {
1085 let zone = (byte >> 4) & 0x0F;
1086 let digit = byte & 0x0F;
1087
1088 if digit > 9 {
1089 return Err(Error::new(
1090 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1091 format!("Invalid digit nibble 0x{digit:X} at position {i}"),
1092 ));
1093 }
1094
1095 if zone != expected_zone {
1096 let zone_label = match codepage {
1097 Codepage::ASCII => "ASCII",
1098 _ => "EBCDIC",
1099 };
1100 return Err(Error::new(
1101 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1102 format!(
1103 "Invalid {zone_label} zone 0x{zone:X} at position {i}, expected 0x{expected_zone:X}"
1104 ),
1105 ));
1106 }
1107
1108 value = value.saturating_mul(10).saturating_add(i64::from(digit));
1109 }
1110 }
1111
1112 let mut decimal = SmallDecimal::new(value, scale, is_negative);
1113 decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
1114 Ok(decimal)
1115}
1116
1117/// Decode a zoned decimal field with SIGN SEPARATE clause
1118///
1119/// SIGN SEPARATE stores the sign in a separate byte rather than overpunching
1120/// it in the zone portion of the last digit. The sign byte can be leading
1121/// (before digits) or trailing (after digits).
1122///
1123/// # Arguments
1124/// * `data` - Raw byte data (includes sign byte + digit bytes)
1125/// * `digits` - Number of digit characters (not including sign byte)
1126/// * `scale` - Decimal places (can be negative for scaling)
1127/// * `sign_separate` - SIGN SEPARATE clause information (placement)
1128/// * `codepage` - Character encoding (ASCII or EBCDIC)
1129///
1130/// # Returns
1131/// A `SmallDecimal` containing the decoded value
1132///
1133/// # Errors
1134/// Returns an error if data length is incorrect or sign byte is invalid.
1135///
1136/// # Examples
1137///
1138/// ## Leading Sign (ASCII)
1139///
1140/// ```no_run
1141/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
1142/// use copybook_codec::options::Codepage;
1143/// use copybook_core::SignPlacement;
1144/// use copybook_core::SignSeparateInfo;
1145///
1146/// // "+123" with leading sign: [0x2B, 0x31, 0x32, 0x33]
1147/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
1148/// let data = [b'+', b'1', b'2', b'3'];
1149/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
1150/// assert_eq!(result.to_string(), "123");
1151/// # Ok::<(), copybook_core::Error>(())
1152/// ```
1153///
1154/// ## Trailing Sign (ASCII)
1155///
1156/// ```no_run
1157/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
1158/// use copybook_codec::options::Codepage;
1159/// use copybook_core::SignPlacement;
1160/// use copybook_core::SignSeparateInfo;
1161///
1162/// // "-456" with trailing sign: [0x34, 0x35, 0x36, 0x2D]
1163/// let sign_info = SignSeparateInfo { placement: SignPlacement::Trailing };
1164/// let data = [b'4', b'5', b'6', b'-'];
1165/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
1166/// assert_eq!(result.to_string(), "-456");
1167/// # Ok::<(), copybook_core::Error>(())
1168/// ```
1169///
1170/// ## EBCDIC Leading Sign
1171///
1172/// ```no_run
1173/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
1174/// use copybook_codec::options::Codepage;
1175/// use copybook_core::SignPlacement;
1176/// use copybook_core::SignSeparateInfo;
1177///
1178/// // "+789" with leading EBCDIC sign: [0x4E, 0xF7, 0xF8, 0xF9]
1179/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
1180/// let data = [0x4E, 0xF7, 0xF8, 0xF9];
1181/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::CP037)?;
1182/// assert_eq!(result.to_string(), "789");
1183/// # Ok::<(), copybook_core::Error>(())
1184/// ```
1185///
1186/// # See Also
1187/// * [`decode_zoned_decimal`] - For overpunch-encoded zoned decimals
1188/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
1189#[inline]
1190#[must_use = "Handle the Result or propagate the error"]
1191pub fn decode_zoned_decimal_sign_separate(
1192 data: &[u8],
1193 digits: u16,
1194 scale: i16,
1195 sign_separate: &SignSeparateInfo,
1196 codepage: Codepage,
1197) -> Result<SmallDecimal> {
1198 // SIGN SEPARATE adds 1 byte for the sign
1199 let expected_len = usize::from(digits) + 1;
1200
1201 if unlikely(data.len() != expected_len) {
1202 return Err(Error::new(
1203 ErrorCode::CBKD301_RECORD_TOO_SHORT,
1204 format!(
1205 "SIGN SEPARATE zoned decimal data length mismatch: expected {} bytes, got {}",
1206 expected_len,
1207 data.len()
1208 ),
1209 ));
1210 }
1211
1212 // Determine sign byte and digit bytes based on placement
1213 let (sign_byte, digit_bytes) = match sign_separate.placement {
1214 SignPlacement::Leading => {
1215 // Sign byte is first, digits follow
1216 if data.is_empty() {
1217 return Err(Error::new(
1218 ErrorCode::CBKD301_RECORD_TOO_SHORT,
1219 "SIGN SEPARATE field is empty",
1220 ));
1221 }
1222 (data[0], &data[1..])
1223 }
1224 SignPlacement::Trailing => {
1225 // Digits are first, sign byte is last
1226 if data.is_empty() {
1227 return Err(Error::new(
1228 ErrorCode::CBKD301_RECORD_TOO_SHORT,
1229 "SIGN SEPARATE field is empty",
1230 ));
1231 }
1232 (data[data.len() - 1], &data[..data.len() - 1])
1233 }
1234 };
1235
1236 // Decode sign byte
1237
1238 let is_negative = if codepage.is_ascii() {
1239 match sign_byte {
1240 b'-' => true,
1241
1242 b'+' | b' ' | b'0' => false, // Space or zero means positive/unsigned
1243
1244 _ => {
1245 return Err(Error::new(
1246 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1247 format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (ASCII)"),
1248 ));
1249 }
1250 }
1251 } else {
1252 // EBCDIC codepage (CP037, CP273, CP500, CP1047, CP1140)
1253
1254 match sign_byte {
1255 0x60 => true, // EBCDIC '-'
1256
1257 0x4E | 0x40 | 0xF0 => false, // Space or zero means positive/unsigned
1258
1259 _ => {
1260 return Err(Error::new(
1261 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1262 format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (EBCDIC)"),
1263 ));
1264 }
1265 }
1266 };
1267
1268 // Decode digit bytes
1269
1270 let mut value: i64 = 0;
1271
1272 for &byte in digit_bytes {
1273 let digit = if codepage.is_ascii() {
1274 if !byte.is_ascii_digit() {
1275 return Err(Error::new(
1276 ErrorCode::CBKD301_RECORD_TOO_SHORT,
1277 format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (ASCII)"),
1278 ));
1279 }
1280
1281 byte - b'0'
1282 } else {
1283 // EBCDIC digits are 0xF0-0xF9
1284
1285 if !(0xF0..=0xF9).contains(&byte) {
1286 return Err(Error::new(
1287 ErrorCode::CBKD301_RECORD_TOO_SHORT,
1288 format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (EBCDIC)"),
1289 ));
1290 }
1291
1292 byte - 0xF0
1293 };
1294
1295 value = value
1296 .checked_mul(10)
1297 .and_then(|v| v.checked_add(i64::from(digit)))
1298 .ok_or_else(|| {
1299 Error::new(
1300 ErrorCode::CBKD410_ZONED_OVERFLOW,
1301 format!("SIGN SEPARATE zoned decimal value overflow for {digits} digits"),
1302 )
1303 })?;
1304 }
1305
1306 let mut decimal = SmallDecimal::new(value, scale, is_negative);
1307 decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
1308 Ok(decimal)
1309}
1310
1311/// Encode a zoned decimal value with SIGN SEPARATE clause.
1312///
1313/// The SIGN SEPARATE clause places the sign character in a separate byte
1314/// (leading or trailing) rather than overpunching the last digit.
1315///
1316/// Total encoded length = digits + 1 (for the separate sign byte).
1317///
1318/// # Arguments
1319/// * `value` - String representation of the numeric value (e.g., "123", "-456.78")
1320/// * `digits` - Number of digit positions in the field
1321/// * `scale` - Number of implied decimal places
1322/// * `sign_separate` - Sign placement information (leading or trailing)
1323/// * `codepage` - Character encoding (determines sign byte encoding)
1324/// * `buffer` - Output buffer (must be at least digits + 1 bytes)
1325///
1326/// # Errors
1327/// Returns `CBKE530_SIGN_SEPARATE_ENCODE_ERROR` if the value cannot be encoded.
1328#[inline]
1329pub fn encode_zoned_decimal_sign_separate(
1330 value: &str,
1331 digits: u16,
1332 scale: i16,
1333 sign_separate: &SignSeparateInfo,
1334 codepage: Codepage,
1335 buffer: &mut [u8],
1336) -> Result<()> {
1337 let expected_len = usize::from(digits) + 1;
1338 if buffer.len() < expected_len {
1339 return Err(Error::new(
1340 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1341 format!(
1342 "SIGN SEPARATE encode buffer too small: need {expected_len} bytes, got {}",
1343 buffer.len()
1344 ),
1345 ));
1346 }
1347
1348 // Parse value string to determine sign and digit characters
1349 let trimmed = value.trim();
1350 let (is_negative, abs_str) = if let Some(rest) = trimmed.strip_prefix('-') {
1351 (true, rest)
1352 } else if let Some(rest) = trimmed.strip_prefix('+') {
1353 (false, rest)
1354 } else {
1355 (false, trimmed)
1356 };
1357
1358 // Validate input characters before scaling
1359 for ch in abs_str.chars() {
1360 if !ch.is_ascii_digit() && ch != '.' {
1361 return Err(Error::new(
1362 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1363 format!("Unexpected character '{ch}' in numeric value '{value}'"),
1364 ));
1365 }
1366 }
1367
1368 // Structural validation: reject ambiguous/empty numeric input
1369 let dot_count = abs_str.chars().filter(|&c| c == '.').count();
1370 let digit_count = abs_str.chars().filter(char::is_ascii_digit).count();
1371
1372 if digit_count == 0 {
1373 return Err(Error::new(
1374 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1375 format!("No digits found in numeric value '{value}'"),
1376 ));
1377 }
1378 if dot_count > 1 {
1379 return Err(Error::new(
1380 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1381 format!("Multiple decimal points in numeric value '{value}'"),
1382 ));
1383 }
1384 if scale <= 0 && dot_count == 1 {
1385 return Err(Error::new(
1386 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1387 format!("Unexpected decimal point for scale {scale} in value '{value}'"),
1388 ));
1389 }
1390
1391 // Build the scaled digit string
1392 let scaled = build_scaled_digit_string(abs_str, scale);
1393
1394 // Pad with leading zeros or truncate to match digit count
1395 let digits_usize = usize::from(digits);
1396 let padded = match scaled.len().cmp(&digits_usize) {
1397 std::cmp::Ordering::Less => {
1398 format!("{scaled:0>digits_usize$}")
1399 }
1400 std::cmp::Ordering::Greater => {
1401 return Err(Error::new(
1402 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1403 format!(
1404 "SIGN SEPARATE overflow: value requires {} digits but field allows {}",
1405 scaled.len(),
1406 digits_usize
1407 ),
1408 ));
1409 }
1410 std::cmp::Ordering::Equal => scaled,
1411 };
1412
1413 // Determine sign byte and digit encoding based on codepage
1414 let (sign_byte, digit_base): (u8, u8) = if codepage.is_ascii() {
1415 (if is_negative { b'-' } else { b'+' }, b'0')
1416 } else {
1417 // EBCDIC codepages
1418 (if is_negative { 0x60 } else { 0x4E }, 0xF0)
1419 };
1420
1421 // Write digits to buffer
1422 let digit_offset = match sign_separate.placement {
1423 SignPlacement::Leading => {
1424 buffer[0] = sign_byte;
1425 1
1426 }
1427 SignPlacement::Trailing => 0,
1428 };
1429
1430 for (i, byte) in padded.bytes().enumerate() {
1431 let digit = byte.wrapping_sub(b'0');
1432 if digit > 9 {
1433 return Err(Error::new(
1434 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1435 format!("Invalid digit byte 0x{byte:02X} in value"),
1436 ));
1437 }
1438 buffer[digit_offset + i] = digit_base + digit;
1439 }
1440
1441 if matches!(sign_separate.placement, SignPlacement::Trailing) {
1442 buffer[digits_usize] = sign_byte;
1443 }
1444
1445 Ok(())
1446}
1447
1448/// Build a digit string scaled to the given number of decimal places.
1449///
1450/// Splits the absolute value string at the decimal point (if present),
1451/// pads or truncates the fractional part to `scale` digits, and concatenates.
1452fn build_scaled_digit_string(abs_str: &str, scale: i16) -> String {
1453 // Extract only digit characters (ignoring other formatting)
1454 let digit_str: String = abs_str.chars().filter(char::is_ascii_digit).collect();
1455
1456 if scale <= 0 {
1457 return digit_str;
1458 }
1459
1460 let scale_usize = usize::try_from(scale).unwrap_or(0);
1461 let (integer_part, fractional_part) = if let Some(pos) = abs_str.find('.') {
1462 (&abs_str[..pos], &abs_str[pos + 1..])
1463 } else {
1464 (abs_str, "")
1465 };
1466
1467 let int_digits: String = integer_part.chars().filter(char::is_ascii_digit).collect();
1468 let frac_digits: String = fractional_part
1469 .chars()
1470 .filter(char::is_ascii_digit)
1471 .collect();
1472
1473 // Pad or truncate fractional part to match scale
1474 let padded_frac = if frac_digits.len() >= scale_usize {
1475 frac_digits[..scale_usize].to_string()
1476 } else {
1477 format!("{frac_digits:0<scale_usize$}")
1478 };
1479 format!("{int_digits}{padded_frac}")
1480}
1481
1482/// Decode zoned decimal field with encoding detection and preservation
1483///
1484/// Returns both the decoded decimal and encoding information for preservation.
1485/// When `preserve_encoding` is true, analyzes the input data to detect
1486/// whether it uses ASCII or EBCDIC encoding, and whether mixed encodings
1487/// are present within the field.
1488///
1489/// # Arguments
1490/// * `data` - Raw byte data containing the zoned decimal
1491/// * `digits` - Number of digit characters (field length)
1492/// * `scale` - Number of decimal places (can be negative for scaling)
1493/// * `signed` - Whether the field is signed (true) or unsigned (false)
1494/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1495/// * `blank_when_zero` - If true, all-space fields decode as zero
1496/// * `preserve_encoding` - If true, detect and return encoding information
1497///
1498/// # Returns
1499/// A tuple of (`SmallDecimal`, `Option<ZonedEncodingInfo>`) containing:
1500/// - The decoded decimal value
1501/// - Encoding information (if `preserve_encoding` was true)
1502///
1503/// # Policy
1504/// Mirrors [`decode_zoned_decimal`], defaulting to preferred-zero handling for EBCDIC unless a preserved format dictates otherwise.
1505///
1506/// # Errors
1507/// Returns an error if the zoned decimal data is invalid or contains bad sign zones.
1508/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
1509///
1510/// # Examples
1511///
1512/// ## Basic Decoding Without Preservation
1513///
1514/// ```no_run
1515/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
1516/// use copybook_codec::options::Codepage;
1517///
1518/// let data = b"123";
1519/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
1520/// data, 3, 0, false, Codepage::ASCII, false, false
1521/// )?;
1522/// assert_eq!(decimal.to_string(), "123");
1523/// assert!(encoding_info.is_none());
1524/// # Ok::<(), copybook_core::Error>(())
1525/// ```
1526///
1527/// ## Encoding Detection
1528///
1529/// ```no_run
1530/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
1531/// use copybook_codec::options::Codepage;
1532/// use copybook_codec::options::ZonedEncodingFormat;
1533///
1534/// let data = b"123";
1535/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
1536/// data, 3, 0, false, Codepage::ASCII, false, true
1537/// )?;
1538/// assert_eq!(decimal.to_string(), "123");
1539/// let info = encoding_info.unwrap();
1540/// assert_eq!(info.detected_format, ZonedEncodingFormat::Ascii);
1541/// assert!(!info.has_mixed_encoding);
1542/// # Ok::<(), copybook_core::Error>(())
1543/// ```
1544///
1545/// # See Also
1546/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
1547/// * [`ZonedEncodingInfo`] - For encoding detection results
1548#[inline]
1549#[must_use = "Handle the Result or propagate the error"]
1550pub fn decode_zoned_decimal_with_encoding(
1551 data: &[u8],
1552 digits: u16,
1553 scale: i16,
1554 signed: bool,
1555 codepage: Codepage,
1556 blank_when_zero: bool,
1557 preserve_encoding: bool,
1558) -> Result<(SmallDecimal, Option<ZonedEncodingInfo>)> {
1559 if data.len() != usize::from(digits) {
1560 return Err(Error::new(
1561 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1562 format!(
1563 "Zoned decimal data length {} doesn't match digits {}",
1564 data.len(),
1565 digits
1566 ),
1567 ));
1568 }
1569
1570 // Check for BLANK WHEN ZERO (all spaces)
1571 let is_all_spaces = data.iter().all(|&b| {
1572 match codepage {
1573 Codepage::ASCII => b == b' ',
1574 _ => b == 0x40, // EBCDIC space
1575 }
1576 });
1577
1578 if is_all_spaces {
1579 if blank_when_zero {
1580 warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
1581 crate::lib_api::increment_warning_counter();
1582 return Ok((SmallDecimal::zero(scale), None));
1583 }
1584 return Err(Error::new(
1585 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1586 "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
1587 ));
1588 }
1589
1590 // Detect encoding if preservation is enabled
1591 let encoding_info = if preserve_encoding {
1592 Some(ZonedEncodingInfo::detect_from_data(data)?)
1593 } else {
1594 None
1595 };
1596
1597 // Check for mixed encoding error
1598 if let Some(ref info) = encoding_info
1599 && info.has_mixed_encoding
1600 {
1601 return Err(Error::new(
1602 ErrorCode::CBKD414_ZONED_MIXED_ENCODING,
1603 "Mixed ASCII/EBCDIC encoding detected within zoned decimal field",
1604 ));
1605 }
1606
1607 let (value, is_negative) =
1608 zoned_decode_digits_with_encoding(data, signed, codepage, preserve_encoding)?;
1609
1610 let mut decimal = SmallDecimal::new(value, scale, is_negative);
1611 decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
1612 Ok((decimal, encoding_info))
1613}
1614
1615/// Internal helper to decode zoned decimal digits with encoding detection
1616///
1617/// This function handles the core logic of iterating through zoned decimal bytes,
1618/// accumulating the numeric value, and optionally detecting/validating the encoding.
1619///
1620/// # Arguments
1621/// * `data` - Raw byte data containing the zoned decimal
1622/// * `signed` - Whether the field is signed
1623/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1624/// * `preserve_encoding` - If true, validate consistent encoding throughout the field
1625///
1626/// # Returns
1627/// A tuple of (`accumulated_value`, `is_negative`)
1628///
1629/// # Errors
1630/// Returns an error if an invalid digit or zone nibble is encountered.
1631#[inline]
1632fn zoned_decode_digits_with_encoding(
1633 data: &[u8],
1634 signed: bool,
1635 codepage: Codepage,
1636 preserve_encoding: bool,
1637) -> Result<(i64, bool)> {
1638 let mut value = 0i64;
1639 let mut is_negative = false;
1640
1641 for (index, &byte) in data.iter().enumerate() {
1642 let zone = (byte >> 4) & 0x0F;
1643
1644 if index == data.len() - 1 {
1645 let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
1646
1647 if signed {
1648 is_negative = negative;
1649 } else {
1650 let zone_valid = if preserve_encoding {
1651 matches!(zone, 0x3 | 0xF)
1652 } else {
1653 match codepage {
1654 Codepage::ASCII => zone == 0x3,
1655 _ => zone == 0xF,
1656 }
1657 };
1658
1659 if !zone_valid {
1660 let message = if preserve_encoding {
1661 format!(
1662 "Invalid zone 0x{zone:X} in unsigned zoned decimal, expected 0x3 (ASCII) or 0xF (EBCDIC)"
1663 )
1664 } else {
1665 let zone_label = zoned_zone_label(codepage);
1666 format!(
1667 "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
1668 )
1669 };
1670 let code = if preserve_encoding {
1671 ErrorCode::CBKD413_ZONED_INVALID_ENCODING
1672 } else {
1673 ErrorCode::CBKD411_ZONED_BAD_SIGN
1674 };
1675 return Err(Error::new(code, message));
1676 }
1677
1678 if negative {
1679 return Err(Error::new(
1680 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1681 "Unsigned zoned decimal contains negative overpunch",
1682 ));
1683 }
1684 }
1685
1686 value = value.saturating_mul(10).saturating_add(i64::from(digit));
1687 } else {
1688 let digit = byte & 0x0F;
1689 if digit > 9 {
1690 return Err(Error::new(
1691 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1692 format!("Invalid digit nibble 0x{digit:X} at position {index}"),
1693 ));
1694 }
1695
1696 if preserve_encoding {
1697 match zone {
1698 0x3 | 0xF => {}
1699 _ => {
1700 return Err(Error::new(
1701 ErrorCode::CBKD413_ZONED_INVALID_ENCODING,
1702 format!(
1703 "Invalid zone 0x{zone:X} at position {index}, expected 0x3 (ASCII) or 0xF (EBCDIC)"
1704 ),
1705 ));
1706 }
1707 }
1708 } else {
1709 match codepage {
1710 Codepage::ASCII => {
1711 if zone != 0x3 {
1712 return Err(Error::new(
1713 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1714 format!(
1715 "Invalid ASCII zone 0x{zone:X} at position {index}, expected 0x3"
1716 ),
1717 ));
1718 }
1719 }
1720 _ => {
1721 if zone != 0xF {
1722 return Err(Error::new(
1723 ErrorCode::CBKD411_ZONED_BAD_SIGN,
1724 format!(
1725 "Invalid EBCDIC zone 0x{zone:X} at position {index}, expected 0xF"
1726 ),
1727 ));
1728 }
1729 }
1730 }
1731 }
1732
1733 value = value.saturating_mul(10).saturating_add(i64::from(digit));
1734 }
1735 }
1736
1737 Ok((value, is_negative))
1738}
1739
1740/// Decode packed decimal (COMP-3) field with comprehensive error context
1741///
1742/// Decodes COMP-3 packed decimal format where each byte contains two decimal
1743/// digits (nibbles), with the last nibble containing the sign. This function
1744/// uses optimized fast paths for common enterprise data patterns.
1745///
1746/// # Arguments
1747/// * `data` - Raw byte data containing the packed decimal
1748/// * `digits` - Number of decimal digits in the field (1-18 supported)
1749/// * `scale` - Number of decimal places (can be negative for scaling)
1750/// * `signed` - Whether the field is signed (true) or unsigned (false)
1751///
1752/// # Returns
1753/// A `SmallDecimal` containing the decoded value
1754///
1755/// # Errors
1756/// Returns an error if the packed decimal data contains invalid nibbles.
1757/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
1758///
1759/// # Performance
1760/// This function uses specialized fast paths for common cases:
1761/// - 1-5 byte fields: Direct decoding with minimal validation
1762/// - Empty data: Immediate zero return
1763/// - Digits > 18: Error (maximum supported precision)
1764///
1765/// # Examples
1766///
1767/// ## Basic Positive Value
1768///
1769/// ```no_run
1770/// use copybook_codec::numeric::{decode_packed_decimal};
1771///
1772/// // "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
1773/// let data = [0x12, 0x3C];
1774/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1775/// assert_eq!(result.to_string(), "123");
1776/// # Ok::<(), copybook_core::Error>(())
1777/// ```
1778///
1779/// ## Negative Value
1780///
1781/// ```no_run
1782/// use copybook_codec::numeric::{decode_packed_decimal};
1783///
1784/// // "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
1785/// let data = [0x04, 0x56, 0xD];
1786/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1787/// assert_eq!(result.to_string(), "-456");
1788/// # Ok::<(), copybook_core::Error>(())
1789/// ```
1790///
1791/// ## Decimal Scale
1792///
1793/// ```no_run
1794/// use copybook_codec::numeric::{decode_packed_decimal};
1795///
1796/// // "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
1797/// let data = [0x12, 0x34, 0xC];
1798/// let result = decode_packed_decimal(&data, 4, 2, true)?;
1799/// assert_eq!(result.to_string(), "12.34");
1800/// # Ok::<(), copybook_core::Error>(())
1801/// ```
1802///
1803/// ## Unsigned Field
1804///
1805/// ```no_run
1806/// use copybook_codec::numeric::{decode_packed_decimal};
1807///
1808/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
1809/// let data = [0x07, 0x89, 0xF];
1810/// let result = decode_packed_decimal(&data, 3, 0, false)?;
1811/// assert_eq!(result.to_string(), "789");
1812/// # Ok::<(), copybook_core::Error>(())
1813/// ```
1814///
1815/// ## Zero Value
1816///
1817/// ```no_run
1818/// use copybook_codec::numeric::{decode_packed_decimal};
1819///
1820/// // Zero: [0x00, 0x0C]
1821/// let data = [0x00, 0x0C];
1822/// let result = decode_packed_decimal(&data, 2, 0, true)?;
1823/// assert_eq!(result.to_string(), "0");
1824/// # Ok::<(), copybook_core::Error>(())
1825/// ```
1826///
1827/// # See Also
1828/// * [`encode_packed_decimal`] - For encoding packed decimals
1829/// * [`decode_packed_decimal_with_scratch`] - For zero-allocation decoding
1830/// * [`decode_packed_decimal_to_string_with_scratch`] - For direct string output
1831#[inline]
1832#[must_use = "Handle the Result or propagate the error"]
1833pub fn decode_packed_decimal(
1834 data: &[u8],
1835 digits: u16,
1836 scale: i16,
1837 signed: bool,
1838) -> Result<SmallDecimal> {
1839 // CRITICAL PERFORMANCE OPTIMIZATION: Ultra-fast path with minimal safety overhead
1840 let expected_bytes = usize::from((digits + 1).div_ceil(2));
1841 // PERFORMANCE CRITICAL: Single branch validation optimized for happy path
1842 if likely(data.len() == expected_bytes && !data.is_empty() && digits <= 18) {
1843 // ULTRA-FAST PATH: Most common enterprise cases with minimal validation
1844 return decode_packed_decimal_fast_path(data, digits, scale, signed);
1845 }
1846
1847 // FALLBACK PATH: Full validation for edge cases
1848 if data.len() != expected_bytes {
1849 return Err(Error::new(
1850 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1851 "Packed decimal data length mismatch".to_string(),
1852 ));
1853 }
1854
1855 if data.is_empty() {
1856 return Ok(SmallDecimal::zero(scale));
1857 }
1858
1859 if digits > 18 {
1860 return Err(Error::new(
1861 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1862 format!(
1863 "COMP-3 field with {digits} digits exceeds maximum supported precision (18 digits max for current implementation)"
1864 ),
1865 ));
1866 }
1867
1868 // Delegate to ultra-fast path
1869 decode_packed_decimal_fast_path(data, digits, scale, signed)
1870}
1871
1872/// Ultra-optimized COMP-3 decoder for hot path performance
1873///
1874/// This function is highly optimized for the 95% case of enterprise COBOL processing
1875/// where COMP-3 fields are 1-5 bytes and well-formed. It selects the appropriate
1876/// specialized decoder based on the data length.
1877#[inline]
1878fn decode_packed_decimal_fast_path(
1879 data: &[u8],
1880 digits: u16,
1881 scale: i16,
1882 signed: bool,
1883) -> Result<SmallDecimal> {
1884 match data.len() {
1885 1 => decode_packed_fast_len1(data[0], digits, scale, signed),
1886 2 => decode_packed_fast_len2(data, digits, scale, signed),
1887 3 => decode_packed_fast_len3(data, scale, signed),
1888 _ => decode_packed_fast_general(data, digits, scale, signed),
1889 }
1890}
1891
1892/// Specialized COMP-3 decoder for 1-byte fields (1 digit)
1893///
1894/// # Arguments
1895/// * `byte` - The single byte of packed decimal data
1896/// * `digits` - Number of digits (should be 1)
1897/// * `scale` - Decimal scale
1898/// * `signed` - Whether the field is signed
1899#[inline]
1900fn decode_packed_fast_len1(
1901 byte: u8,
1902 digits: u16,
1903 scale: i16,
1904 signed: bool,
1905) -> Result<SmallDecimal> {
1906 let high_nibble = (byte >> 4) & 0x0F;
1907 let low_nibble = byte & 0x0F;
1908 let mut value = 0i64;
1909
1910 if !digits.is_multiple_of(2) {
1911 if unlikely(high_nibble > 9) {
1912 return Err(Error::new(
1913 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1914 "Invalid digit nibble in packed decimal".to_string(),
1915 ));
1916 }
1917 value = i64::from(high_nibble);
1918 }
1919
1920 if signed {
1921 let is_negative = match low_nibble {
1922 0xA | 0xC | 0xE | 0xF => false,
1923 0xB | 0xD => true,
1924 _ => {
1925 return Err(Error::new(
1926 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1927 "Invalid sign nibble in packed decimal".to_string(),
1928 ));
1929 }
1930 };
1931 return Ok(create_normalized_decimal(value, scale, is_negative));
1932 }
1933
1934 if unlikely(low_nibble != 0xF) {
1935 return Err(Error::new(
1936 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1937 "Invalid unsigned sign nibble, expected 0xF".to_string(),
1938 ));
1939 }
1940
1941 Ok(create_normalized_decimal(value, scale, false))
1942}
1943
1944/// Specialized COMP-3 decoder for 2-byte fields (2-3 digits)
1945///
1946/// # Arguments
1947/// * `data` - The 2 bytes of packed decimal data
1948/// * `digits` - Number of digits (2 or 3)
1949/// * `scale` - Decimal scale
1950/// * `signed` - Whether the field is signed
1951#[inline]
1952fn decode_packed_fast_len2(
1953 data: &[u8],
1954 digits: u16,
1955 scale: i16,
1956 signed: bool,
1957) -> Result<SmallDecimal> {
1958 let byte0 = data[0];
1959 let byte1 = data[1];
1960
1961 let d1 = (byte0 >> 4) & 0x0F;
1962 let d2 = byte0 & 0x0F;
1963 let d3 = (byte1 >> 4) & 0x0F;
1964 let sign_nibble = byte1 & 0x0F;
1965
1966 let value = if digits == 2 {
1967 if unlikely(d1 != 0) {
1968 return Err(Error::new(
1969 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1970 format!("Expected padding nibble 0 for 2-digit field, got 0x{d1:X}"),
1971 ));
1972 }
1973
1974 if unlikely(d2 > 9 || d3 > 9) {
1975 return Err(Error::new(
1976 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1977 "Invalid digit in 2-digit COMP-3 field".to_string(),
1978 ));
1979 }
1980
1981 i64::from(d2) * 10 + i64::from(d3)
1982 } else {
1983 if unlikely(d1 > 9 || d2 > 9 || d3 > 9) {
1984 return Err(Error::new(
1985 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1986 "Invalid digit in 3-digit COMP-3 field".to_string(),
1987 ));
1988 }
1989
1990 i64::from(d1) * 100 + i64::from(d2) * 10 + i64::from(d3)
1991 };
1992
1993 let is_negative = if signed {
1994 match sign_nibble {
1995 0xA | 0xC | 0xE | 0xF => false,
1996 0xB | 0xD => true,
1997 _ => {
1998 return Err(Error::new(
1999 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2000 "Invalid sign nibble in packed decimal".to_string(),
2001 ));
2002 }
2003 }
2004 } else {
2005 if unlikely(sign_nibble != 0xF) {
2006 return Err(Error::new(
2007 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2008 "Invalid unsigned sign nibble, expected 0xF".to_string(),
2009 ));
2010 }
2011 false
2012 };
2013
2014 Ok(create_normalized_decimal(value, scale, is_negative))
2015}
2016
2017/// Specialized COMP-3 decoder for 3-byte fields (4-5 digits)
2018///
2019/// # Arguments
2020/// * `data` - The 3 bytes of packed decimal data
2021/// * `scale` - Decimal scale
2022/// * `signed` - Whether the field is signed
2023#[inline]
2024fn decode_packed_fast_len3(data: &[u8], scale: i16, signed: bool) -> Result<SmallDecimal> {
2025 let byte0 = data[0];
2026 let byte1 = data[1];
2027 let byte2 = data[2];
2028
2029 let d1 = (byte0 >> 4) & 0x0F;
2030 let d2 = byte0 & 0x0F;
2031 let d3 = (byte1 >> 4) & 0x0F;
2032 let d4 = byte1 & 0x0F;
2033 let d5 = (byte2 >> 4) & 0x0F;
2034 let sign_nibble = byte2 & 0x0F;
2035
2036 if unlikely(d1 > 9 || d2 > 9 || d3 > 9 || d4 > 9 || d5 > 9) {
2037 return Err(Error::new(
2038 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2039 "Invalid digit in 3-byte COMP-3 field".to_string(),
2040 ));
2041 }
2042
2043 let value = i64::from(d1) * 10000
2044 + i64::from(d2) * 1000
2045 + i64::from(d3) * 100
2046 + i64::from(d4) * 10
2047 + i64::from(d5);
2048
2049 let is_negative = if signed {
2050 match sign_nibble {
2051 0xA | 0xC | 0xE | 0xF => false,
2052 0xB | 0xD => true,
2053 _ => {
2054 return Err(Error::new(
2055 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2056 "Invalid sign nibble in packed decimal".to_string(),
2057 ));
2058 }
2059 }
2060 } else {
2061 if unlikely(sign_nibble != 0xF) {
2062 return Err(Error::new(
2063 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2064 "Invalid unsigned sign nibble, expected 0xF".to_string(),
2065 ));
2066 }
2067 false
2068 };
2069
2070 Ok(create_normalized_decimal(value, scale, is_negative))
2071}
2072
2073/// General-purpose COMP-3 decoder for fields longer than 3 bytes
2074///
2075/// Handles multi-byte packed decimal decoding with support for padding
2076/// nibbles and variable digit counts.
2077///
2078/// # Arguments
2079/// * `data` - The packed decimal data bytes
2080/// * `digits` - Number of decimal digits in the field
2081/// * `scale` - Decimal scale
2082/// * `signed` - Whether the field is signed
2083#[inline]
2084fn decode_packed_fast_general(
2085 data: &[u8],
2086 digits: u16,
2087 scale: i16,
2088 signed: bool,
2089) -> Result<SmallDecimal> {
2090 let total_nibbles = digits + 1;
2091 let has_padding = (total_nibbles & 1) == 1;
2092 let digit_count = usize::from(digits);
2093
2094 let Some((last_byte, prefix_bytes)) = data.split_last() else {
2095 return Err(Error::new(
2096 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2097 "Packed decimal data is empty".to_string(),
2098 ));
2099 };
2100 let mut value = 0i64;
2101 let mut digit_pos = 0;
2102
2103 for &byte in prefix_bytes {
2104 let high_nibble = (byte >> 4) & 0x0F;
2105 let low_nibble = byte & 0x0F;
2106
2107 if likely(!(digit_pos == 0 && has_padding)) {
2108 if unlikely(high_nibble > 9) {
2109 return Err(Error::new(
2110 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2111 "Invalid digit nibble".to_string(),
2112 ));
2113 }
2114 value = value * 10 + i64::from(high_nibble);
2115 digit_pos += 1;
2116 }
2117
2118 if unlikely(low_nibble > 9) {
2119 return Err(Error::new(
2120 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2121 "Invalid digit nibble".to_string(),
2122 ));
2123 }
2124 value = value * 10 + i64::from(low_nibble);
2125 digit_pos += 1;
2126 }
2127
2128 let last_high = (*last_byte >> 4) & 0x0F;
2129 let sign_nibble = *last_byte & 0x0F;
2130
2131 if likely(digit_pos < digit_count) {
2132 if unlikely(last_high > 9) {
2133 return Err(Error::new(
2134 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2135 "Invalid digit nibble".to_string(),
2136 ));
2137 }
2138 value = value * 10 + i64::from(last_high);
2139 }
2140
2141 let is_negative = if signed {
2142 match sign_nibble {
2143 0xA | 0xC | 0xE | 0xF => false,
2144 0xB | 0xD => true,
2145 _ => {
2146 return Err(Error::new(
2147 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2148 "Invalid sign nibble".to_string(),
2149 ));
2150 }
2151 }
2152 } else {
2153 if unlikely(sign_nibble != 0xF) {
2154 return Err(Error::new(
2155 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2156 "Invalid unsigned sign nibble".to_string(),
2157 ));
2158 }
2159 false
2160 };
2161
2162 Ok(create_normalized_decimal(value, scale, is_negative))
2163}
2164
2165/// Decode binary integer field (COMP-4, COMP-5, BINARY)
2166///
2167/// Decodes big-endian binary integer fields. Supports 16-bit, 32-bit, and 64-bit
2168/// widths. All binary integers use big-endian byte order as per COBOL specification.
2169///
2170/// # Arguments
2171/// * `data` - Raw byte data containing the binary integer
2172/// * `bits` - Bit width of the field (16, 32, or 64)
2173/// * `signed` - Whether the field is signed (true) or unsigned (false)
2174///
2175/// # Returns
2176/// The decoded integer value as `i64`
2177///
2178/// # Errors
2179/// Returns an error if the binary data is invalid or the field size is unsupported.
2180///
2181/// # Examples
2182///
2183/// ## 16-bit Signed Integer
2184///
2185/// ```no_run
2186/// use copybook_codec::numeric::{decode_binary_int};
2187///
2188/// // 16-bit signed: -12345 = [0xCF, 0xC7] (big-endian)
2189/// let data = [0xCF, 0xC7];
2190/// let result = decode_binary_int(&data, 16, true)?;
2191/// assert_eq!(result, -12345);
2192/// # Ok::<(), copybook_core::Error>(())
2193/// ```
2194///
2195/// ## 16-bit Unsigned Integer
2196///
2197/// ```no_run
2198/// use copybook_codec::numeric::{decode_binary_int};
2199///
2200/// // 16-bit unsigned: 54321 = [0xD4, 0x31]
2201/// let data = [0xD4, 0x31];
2202/// let result = decode_binary_int(&data, 16, false)?;
2203/// assert_eq!(result, 54321);
2204/// # Ok::<(), copybook_core::Error>(())
2205/// ```
2206///
2207/// ## 32-bit Signed Integer
2208///
2209/// ```no_run
2210/// use copybook_codec::numeric::{decode_binary_int};
2211///
2212/// // 32-bit signed: -987654321 = [0xC5, 0x7D, 0x3C, 0x21]
2213/// let data = [0xC5, 0x7D, 0x3C, 0x21];
2214/// let result = decode_binary_int(&data, 32, true)?;
2215/// assert_eq!(result, -987654321);
2216/// # Ok::<(), copybook_core::Error>(())
2217/// ```
2218///
2219/// ## 64-bit Signed Integer
2220///
2221/// ```no_run
2222/// use copybook_codec::numeric::{decode_binary_int};
2223///
2224/// // 64-bit signed: 9223372036854775807 = [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07]
2225/// let data = [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07];
2226/// let result = decode_binary_int(&data, 64, true)?;
2227/// assert_eq!(result, 9223372036854775807);
2228/// # Ok::<(), copybook_core::Error>(())
2229/// ```
2230///
2231/// # See Also
2232/// * [`encode_binary_int`] - For encoding binary integers
2233/// * [`get_binary_width_from_digits`] - For mapping digit count to width
2234/// * [`validate_explicit_binary_width`] - For validating explicit BINARY(n) widths
2235#[inline]
2236#[must_use = "Handle the Result or propagate the error"]
2237pub fn decode_binary_int(data: &[u8], bits: u16, signed: bool) -> Result<i64> {
2238 let expected_bytes = usize::from(bits / 8);
2239 if data.len() != expected_bytes {
2240 return Err(Error::new(
2241 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, // Reusing error code for binary validation
2242 format!(
2243 "Binary data length {} doesn't match expected {} bytes for {} bits",
2244 data.len(),
2245 expected_bytes,
2246 bits
2247 ),
2248 ));
2249 }
2250
2251 match bits {
2252 16 => {
2253 if data.len() != 2 {
2254 return Err(Error::new(
2255 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2256 "16-bit binary field requires exactly 2 bytes",
2257 ));
2258 }
2259 let value = u16::from_be_bytes([data[0], data[1]]);
2260 if signed {
2261 Ok(i64::from(i16::from_be_bytes([data[0], data[1]])))
2262 } else {
2263 Ok(i64::from(value))
2264 }
2265 }
2266 32 => {
2267 if data.len() != 4 {
2268 return Err(Error::new(
2269 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2270 "32-bit binary field requires exactly 4 bytes",
2271 ));
2272 }
2273 let value = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
2274 if signed {
2275 Ok(i64::from(i32::from_be_bytes([
2276 data[0], data[1], data[2], data[3],
2277 ])))
2278 } else {
2279 Ok(i64::from(value))
2280 }
2281 }
2282 64 => {
2283 if data.len() != 8 {
2284 return Err(Error::new(
2285 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2286 "64-bit binary field requires exactly 8 bytes",
2287 ));
2288 }
2289 let bytes: [u8; 8] = data.try_into().map_err(|_| {
2290 Error::new(
2291 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2292 "Failed to convert data to 8-byte array",
2293 )
2294 })?;
2295 if signed {
2296 Ok(i64::from_be_bytes(bytes))
2297 } else {
2298 // For unsigned 64-bit, we need to be careful about overflow
2299 let value = u64::from_be_bytes(bytes);
2300 let max_i64 = u64::try_from(i64::MAX).unwrap_or(u64::MAX);
2301 if value > max_i64 {
2302 return Err(Error::new(
2303 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2304 format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
2305 ));
2306 }
2307 i64::try_from(value).map_err(|_| {
2308 Error::new(
2309 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2310 format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
2311 )
2312 })
2313 }
2314 }
2315 _ => Err(Error::new(
2316 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2317 format!("Unsupported binary field width: {bits} bits"),
2318 )),
2319 }
2320}
2321
2322/// Encode a zoned decimal using the configured code page defaults.
2323///
2324/// Encodes decimal values to zoned decimal format (PIC 9) where each digit is stored
2325/// in a byte with a zone nibble and a digit nibble. For signed fields, the
2326/// last byte uses overpunch encoding for the sign.
2327///
2328/// # Arguments
2329/// * `value` - String representation of the decimal value to encode
2330/// * `digits` - Number of digit characters (field length)
2331/// * `scale` - Number of decimal places (can be negative for scaling)
2332/// * `signed` - Whether the field is signed (true) or unsigned (false)
2333/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
2334///
2335/// # Returns
2336/// A vector of bytes containing the encoded zoned decimal
2337///
2338/// # Policy
2339/// Applies `ZeroSignPolicy::Positive` for ASCII and `ZeroSignPolicy::Preferred` for EBCDIC when no overrides are provided.
2340///
2341/// # Errors
2342/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
2343///
2344/// # Examples
2345///
2346/// ## Basic ASCII Encoding
2347///
2348/// ```no_run
2349/// use copybook_codec::numeric::{encode_zoned_decimal};
2350/// use copybook_codec::options::Codepage;
2351///
2352/// // Encode "123" as ASCII zoned decimal
2353/// let encoded = encode_zoned_decimal("123", 3, 0, false, Codepage::ASCII)?;
2354/// assert_eq!(encoded, b"123"); // [0x31, 0x32, 0x33]
2355/// # Ok::<(), copybook_core::Error>(())
2356/// ```
2357///
2358/// ## Signed ASCII Encoding (Overpunch)
2359///
2360/// ```no_run
2361/// use copybook_codec::numeric::{encode_zoned_decimal};
2362/// use copybook_codec::options::Codepage;
2363///
2364/// // Encode "-456" with overpunch sign
2365/// let encoded = encode_zoned_decimal("-456", 3, 0, true, Codepage::ASCII)?;
2366/// // Last byte 0x4D = 'M' = digit 3 with negative sign
2367/// assert_eq!(encoded, [0x34, 0x35, 0x4D]);
2368/// # Ok::<(), copybook_core::Error>(())
2369/// ```
2370///
2371/// ## EBCDIC Encoding
2372///
2373/// ```no_run
2374/// use copybook_codec::numeric::{encode_zoned_decimal};
2375/// use copybook_codec::options::Codepage;
2376///
2377/// // Encode "789" as EBCDIC zoned decimal
2378/// let encoded = encode_zoned_decimal("789", 3, 0, false, Codepage::CP037)?;
2379/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
2380/// # Ok::<(), copybook_core::Error>(())
2381/// ```
2382///
2383/// ## Decimal Scale
2384///
2385/// ```no_run
2386/// use copybook_codec::numeric::{encode_zoned_decimal};
2387/// use copybook_codec::options::Codepage;
2388///
2389/// // Encode "12.34" with 2 decimal places
2390/// let encoded = encode_zoned_decimal("12.34", 4, 2, false, Codepage::ASCII)?;
2391/// assert_eq!(encoded, b"1234"); // [0x31, 0x32, 0x33, 0x34]
2392/// # Ok::<(), copybook_core::Error>(())
2393/// ```
2394///
2395/// # See Also
2396/// * [`encode_zoned_decimal_with_format`] - For encoding with explicit format
2397/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
2398/// * [`encode_zoned_decimal_with_bwz`] - For encoding with BLANK WHEN ZERO support
2399/// * [`decode_zoned_decimal`] - For decoding zoned decimals
2400#[inline]
2401#[must_use = "Handle the Result or propagate the error"]
2402pub fn encode_zoned_decimal(
2403 value: &str,
2404 digits: u16,
2405 scale: i16,
2406 signed: bool,
2407 codepage: Codepage,
2408) -> Result<Vec<u8>> {
2409 let zero_policy = if codepage.is_ascii() {
2410 ZeroSignPolicy::Positive
2411 } else {
2412 ZeroSignPolicy::Preferred
2413 };
2414
2415 encode_zoned_decimal_with_format_and_policy(
2416 value,
2417 digits,
2418 scale,
2419 signed,
2420 codepage,
2421 None,
2422 zero_policy,
2423 )
2424}
2425
2426/// Encode a zoned decimal using an explicit encoding override when supplied.
2427///
2428/// Encodes zoned decimal values with an explicit encoding format (ASCII or EBCDIC).
2429/// When `encoding_override` is provided, it takes precedence over the codepage default.
2430/// When `Auto` is specified, the codepage default is used.
2431///
2432/// # Arguments
2433/// * `value` - String representation of the decimal value to encode
2434/// * `digits` - Number of digit characters (field length)
2435/// * `scale` - Number of decimal places (can be negative for scaling)
2436/// * `signed` - Whether the field is signed (true) or unsigned (false)
2437/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
2438/// * `encoding_override` - Optional explicit encoding format (ASCII/EBCDIC/Auto)
2439///
2440/// # Returns
2441/// A vector of bytes containing the encoded zoned decimal
2442///
2443/// # Policy
2444/// Resolves `ZeroSignPolicy` from `encoding_override` first; when unset or `Auto`, falls back to the code page defaults.
2445///
2446/// # Errors
2447/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
2448///
2449/// # Examples
2450///
2451/// ## ASCII Encoding (Explicit)
2452///
2453/// ```no_run
2454/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
2455/// use copybook_codec::options::Codepage;
2456/// use copybook_codec::options::ZonedEncodingFormat;
2457///
2458/// // Encode "123" with explicit ASCII encoding
2459/// let encoded = encode_zoned_decimal_with_format(
2460/// "123", 3, 0, false, Codepage::ASCII, Some(ZonedEncodingFormat::Ascii)
2461/// )?;
2462/// assert_eq!(encoded, b"123");
2463/// # Ok::<(), copybook_core::Error>(())
2464/// ```
2465///
2466/// ## EBCDIC Encoding (Explicit)
2467///
2468/// ```no_run
2469/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
2470/// use copybook_codec::options::Codepage;
2471/// use copybook_codec::options::ZonedEncodingFormat;
2472///
2473/// // Encode "789" with explicit EBCDIC encoding
2474/// let encoded = encode_zoned_decimal_with_format(
2475/// "789", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Ebcdic)
2476/// )?;
2477/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
2478/// # Ok::<(), copybook_core::Error>(())
2479/// ```
2480///
2481/// ## Auto Encoding (Codepage Default)
2482///
2483/// ```no_run
2484/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
2485/// use copybook_codec::options::Codepage;
2486/// use copybook_codec::options::ZonedEncodingFormat;
2487///
2488/// // Encode "456" with Auto encoding (uses EBCDIC default for CP037)
2489/// let encoded = encode_zoned_decimal_with_format(
2490/// "456", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Auto)
2491/// )?;
2492/// assert_eq!(encoded, [0xF4, 0xF5, 0xF6]);
2493/// # Ok::<(), copybook_core::Error>(())
2494/// ```
2495///
2496/// # See Also
2497/// * [`encode_zoned_decimal`] - For encoding with codepage defaults
2498/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
2499#[inline]
2500#[must_use = "Handle the Result or propagate the error"]
2501pub fn encode_zoned_decimal_with_format(
2502 value: &str,
2503 digits: u16,
2504 scale: i16,
2505 signed: bool,
2506 codepage: Codepage,
2507 encoding_override: Option<ZonedEncodingFormat>,
2508) -> Result<Vec<u8>> {
2509 let zero_policy = match encoding_override {
2510 Some(ZonedEncodingFormat::Ascii) => ZeroSignPolicy::Positive,
2511 Some(ZonedEncodingFormat::Ebcdic) => ZeroSignPolicy::Preferred,
2512 Some(ZonedEncodingFormat::Auto) | None => {
2513 if codepage.is_ascii() {
2514 ZeroSignPolicy::Positive
2515 } else {
2516 ZeroSignPolicy::Preferred
2517 }
2518 }
2519 };
2520
2521 encode_zoned_decimal_with_format_and_policy(
2522 value,
2523 digits,
2524 scale,
2525 signed,
2526 codepage,
2527 encoding_override,
2528 zero_policy,
2529 )
2530}
2531
2532/// Encode a zoned decimal using a caller-resolved format and zero-sign policy.
2533///
2534/// This is the lowest-level zoned decimal encoder. The caller supplies both the
2535/// encoding format override (ASCII vs EBCDIC) and the zero-sign policy, which
2536/// together govern how the sign nibble of the last byte is produced. Higher-level
2537/// wrappers such as [`encode_zoned_decimal`] and [`encode_zoned_decimal_with_format`]
2538/// resolve these parameters from codec defaults and then delegate here.
2539///
2540/// # Arguments
2541/// * `value` - String representation of the decimal value to encode (e.g. `"123"`, `"-45.67"`)
2542/// * `digits` - Number of digit positions in the COBOL field (PIC digit count)
2543/// * `scale` - Number of implied decimal places (can be negative for scaling)
2544/// * `signed` - Whether the field carries a sign (PIC S9 vs PIC 9)
2545/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
2546/// * `encoding_override` - Explicit format override; `None` falls back to codepage default
2547/// * `zero_policy` - How the sign nibble is encoded for zero values
2548///
2549/// # Returns
2550/// A vector of bytes containing the encoded zoned decimal in the target encoding.
2551///
2552/// # Policy
2553/// Callers provide the resolved policy in precedence order:
2554/// override → preserved metadata → preferred for the target code page.
2555///
2556/// # Errors
2557/// * `CBKE510_NUMERIC_OVERFLOW` - if the value is too large for the digit count
2558/// * `CBKE501_JSON_TYPE_MISMATCH` - if the input contains non-digit characters
2559///
2560/// # See Also
2561/// * [`encode_zoned_decimal`] - Convenience wrapper that resolves policy from the codepage
2562/// * [`encode_zoned_decimal_with_format`] - Accepts a format override without an explicit policy
2563/// * [`encode_zoned_decimal_with_bwz`] - Adds BLANK WHEN ZERO support
2564#[inline]
2565#[must_use = "Handle the Result or propagate the error"]
2566pub fn encode_zoned_decimal_with_format_and_policy(
2567 value: &str,
2568 digits: u16,
2569 scale: i16,
2570 signed: bool,
2571 codepage: Codepage,
2572 encoding_override: Option<ZonedEncodingFormat>,
2573 zero_policy: ZeroSignPolicy,
2574) -> Result<Vec<u8>> {
2575 // Parse the input value with scale validation (NORMATIVE)
2576 let decimal = SmallDecimal::from_str(value, scale)?;
2577
2578 // Convert to string representation of digits
2579 let abs_value = decimal.value.abs();
2580 let width = usize::from(digits);
2581 let digit_str = format!("{abs_value:0width$}");
2582
2583 if digit_str.len() > width {
2584 return Err(Error::new(
2585 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
2586 format!("Value too large for {digits} digits"),
2587 ));
2588 }
2589
2590 // Determine the encoding format to use
2591 // Precedence: explicit override > codepage default
2592 let mut target_format = encoding_override.unwrap_or(match codepage {
2593 Codepage::ASCII => ZonedEncodingFormat::Ascii,
2594 _ => ZonedEncodingFormat::Ebcdic,
2595 });
2596 if target_format == ZonedEncodingFormat::Auto {
2597 target_format = if codepage.is_ascii() {
2598 ZonedEncodingFormat::Ascii
2599 } else {
2600 ZonedEncodingFormat::Ebcdic
2601 };
2602 }
2603
2604 let mut result = Vec::with_capacity(width);
2605 let digit_bytes = digit_str.as_bytes();
2606
2607 // Encode each digit
2608 for (i, &ascii_digit) in digit_bytes.iter().enumerate() {
2609 let digit = ascii_digit - b'0';
2610 if digit > 9 {
2611 return Err(Error::new(
2612 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2613 format!("Invalid digit character: {}", ascii_digit as char),
2614 ));
2615 }
2616
2617 if i == digit_bytes.len() - 1 && signed {
2618 if target_format == ZonedEncodingFormat::Ascii {
2619 let overpunch_byte = encode_overpunch_byte(
2620 digit,
2621 decimal.negative,
2622 Codepage::ASCII,
2623 ZeroSignPolicy::Positive,
2624 )?;
2625 result.push(overpunch_byte);
2626 } else {
2627 let encode_codepage = if codepage == Codepage::ASCII {
2628 Codepage::CP037
2629 } else {
2630 codepage
2631 };
2632 let overpunch_byte =
2633 encode_overpunch_byte(digit, decimal.negative, encode_codepage, zero_policy)?;
2634 result.push(overpunch_byte);
2635 }
2636 } else {
2637 let zone = match target_format {
2638 ZonedEncodingFormat::Ascii => ASCII_DIGIT_ZONE,
2639 _ => EBCDIC_DIGIT_ZONE,
2640 };
2641 result.push((zone << 4) | digit);
2642 }
2643 }
2644
2645 Ok(result)
2646}
2647
2648/// Encode packed decimal (COMP-3) field
2649///
2650/// Encodes decimal values to COMP-3 packed decimal format where each byte contains
2651/// two decimal digits (nibbles), with the last nibble containing the sign.
2652/// This function is optimized for high-throughput enterprise data processing.
2653///
2654/// # Arguments
2655/// * `value` - String representation of the decimal value to encode
2656/// * `digits` - Number of decimal digits in the field (1-18 supported)
2657/// * `scale` - Number of decimal places (can be negative for scaling)
2658/// * `signed` - Whether the field is signed (true) or unsigned (false)
2659///
2660/// # Returns
2661/// A vector of bytes containing the encoded packed decimal
2662///
2663/// # Errors
2664/// Returns an error if the value cannot be encoded as a packed decimal with the specified parameters.
2665///
2666/// # Performance
2667/// This function uses optimized digit extraction to avoid `format!()` allocation overhead.
2668///
2669/// # Examples
2670///
2671/// ## Basic Positive Value
2672///
2673/// ```no_run
2674/// use copybook_codec::numeric::{encode_packed_decimal};
2675///
2676/// // Encode "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
2677/// let encoded = encode_packed_decimal("123", 3, 0, true)?;
2678/// assert_eq!(encoded, [0x12, 0x3C]);
2679/// # Ok::<(), copybook_core::Error>(())
2680/// ```
2681///
2682/// ## Negative Value
2683///
2684/// ```no_run
2685/// use copybook_codec::numeric::{encode_packed_decimal};
2686///
2687/// // Encode "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
2688/// let encoded = encode_packed_decimal("-456", 3, 0, true)?;
2689/// assert_eq!(encoded, [0x04, 0x56, 0xD]);
2690/// # Ok::<(), copybook_core::Error>(())
2691/// ```
2692///
2693/// ## Decimal Scale
2694///
2695/// ```no_run
2696/// use copybook_codec::numeric::{encode_packed_decimal};
2697///
2698/// // Encode "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
2699/// let encoded = encode_packed_decimal("12.34", 4, 2, true)?;
2700/// assert_eq!(encoded, [0x12, 0x34, 0xC]);
2701/// # Ok::<(), copybook_core::Error>(())
2702/// ```
2703///
2704/// ## Unsigned Field
2705///
2706/// ```no_run
2707/// use copybook_codec::numeric::{encode_packed_decimal};
2708///
2709/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
2710/// let encoded = encode_packed_decimal("789", 3, 0, false)?;
2711/// assert_eq!(encoded, [0x07, 0x89, 0xF]);
2712/// # Ok::<(), copybook_core::Error>(())
2713/// ```
2714///
2715/// ## Zero Value
2716///
2717/// ```no_run
2718/// use copybook_codec::numeric::{encode_packed_decimal};
2719///
2720/// // Zero: [0x00, 0x0C]
2721/// let encoded = encode_packed_decimal("0", 2, 0, true)?;
2722/// assert_eq!(encoded, [0x00, 0x0C]);
2723/// # Ok::<(), copybook_core::Error>(())
2724/// ```
2725///
2726/// # See Also
2727/// * [`decode_packed_decimal`] - For decoding packed decimals
2728/// * [`encode_packed_decimal_with_scratch`] - For zero-allocation encoding
2729#[inline]
2730#[must_use = "Handle the Result or propagate the error"]
2731pub fn encode_packed_decimal(
2732 value: &str,
2733 digits: u16,
2734 scale: i16,
2735 signed: bool,
2736) -> Result<Vec<u8>> {
2737 // Parse the input value with scale validation (NORMATIVE)
2738 let decimal = SmallDecimal::from_str(value, scale)?;
2739
2740 // CRITICAL PERFORMANCE OPTIMIZATION: Avoid format!() allocation
2741 // Direct integer-to-digits conversion for massive speedup
2742 let abs_value = decimal.value.abs();
2743
2744 // Fast path for zero
2745 if abs_value == 0 {
2746 let expected_bytes = usize::from((digits + 1).div_ceil(2));
2747 let mut result = vec![0u8; expected_bytes];
2748 // Set sign in last byte
2749 let sign_nibble = if signed {
2750 if decimal.negative { 0x0D } else { 0x0C }
2751 } else {
2752 0x0F
2753 };
2754 result[expected_bytes - 1] = sign_nibble;
2755 return Ok(result);
2756 }
2757
2758 // Pre-allocate digit buffer on stack for speed (up to 18 digits for i64::MAX)
2759 let mut digit_buffer: [u8; 20] = [0; 20];
2760 let mut digit_count = 0;
2761 let mut temp_value = abs_value;
2762
2763 // Extract digits in reverse order using fast division
2764 while temp_value > 0 {
2765 digit_buffer[digit_count] = digit_from_value(temp_value % 10);
2766 temp_value /= 10;
2767 digit_count += 1;
2768 }
2769
2770 // Validate digit count
2771 let digits_usize = usize::from(digits);
2772 if unlikely(digit_count > digits_usize) {
2773 return Err(Error::new(
2774 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
2775 format!("Value too large for {digits} digits"),
2776 ));
2777 }
2778
2779 let expected_bytes = usize::from((digits + 1).div_ceil(2));
2780 let mut result = Vec::with_capacity(expected_bytes);
2781
2782 // CRITICAL FIX: Handle digit positioning correctly for even/odd digit counts
2783 // For packed decimal, we have:
2784 // - Total nibbles needed: digits + 1 (for sign)
2785 // - If digits is even: first nibble is padding (0), then digits, then sign
2786 // - If digits is odd: no padding, digits fill completely, then sign
2787
2788 let has_padding = digits.is_multiple_of(2); // Even digit count requires padding
2789 let total_nibbles = digits_usize + 1 + usize::from(has_padding);
2790
2791 for byte_idx in 0..expected_bytes {
2792 let mut byte_val = 0u8;
2793
2794 // Calculate which nibbles belong to this byte
2795 let nibble_offset = byte_idx * 2;
2796
2797 // High nibble
2798 let high_nibble_idx = nibble_offset;
2799 if high_nibble_idx < total_nibbles - 1 {
2800 // Not the sign nibble
2801 if has_padding && high_nibble_idx == 0 {
2802 // First nibble is padding for even digit count
2803 byte_val |= 0x00 << 4;
2804 } else {
2805 // Calculate which digit this represents
2806 let digit_idx = if has_padding {
2807 high_nibble_idx - 1
2808 } else {
2809 high_nibble_idx
2810 };
2811
2812 // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
2813 // For field width of 'digits', actual digits should occupy the rightmost positions
2814 if digit_idx >= (digits_usize - digit_count) {
2815 // This position should contain an actual digit
2816 let actual_digit_idx = digit_idx - (digits_usize - digit_count);
2817 if actual_digit_idx < digit_count {
2818 // Digits are stored in reverse order (least significant first)
2819 let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
2820 let digit = digit_buffer[digit_pos_from_right];
2821 byte_val |= digit << 4;
2822 }
2823 }
2824 // else: leading zero for large digit field (byte_val already initialized to 0)
2825 }
2826 }
2827
2828 // Low nibble
2829 let low_nibble_idx = nibble_offset + 1;
2830 if low_nibble_idx == total_nibbles - 1 {
2831 // This is the sign nibble
2832 byte_val |= if signed {
2833 if decimal.negative { 0x0D } else { 0x0C }
2834 } else {
2835 0x0F
2836 };
2837 } else if low_nibble_idx < total_nibbles - 1 {
2838 // Calculate which digit this represents
2839 let digit_idx = if has_padding {
2840 low_nibble_idx - 1
2841 } else {
2842 low_nibble_idx
2843 };
2844
2845 // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
2846 // For field width of 'digits', actual digits should occupy the rightmost positions
2847 if digit_idx >= (digits_usize - digit_count) {
2848 // This position should contain an actual digit
2849 let actual_digit_idx = digit_idx - (digits_usize - digit_count);
2850 if actual_digit_idx < digit_count {
2851 // Digits are stored in reverse order (least significant first)
2852 let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
2853 let digit = digit_buffer[digit_pos_from_right];
2854 byte_val |= digit;
2855 }
2856 }
2857 // else: leading zero for large digit field (byte_val already initialized to 0)
2858 }
2859
2860 result.push(byte_val);
2861 }
2862
2863 Ok(result)
2864}
2865
2866/// Encode binary integer field (COMP-4, COMP-5, BINARY)
2867///
2868/// Encodes integer values to big-endian binary format. Supports 16-bit, 32-bit, and 64-bit
2869/// widths. All binary integers use big-endian byte order as per COBOL specification.
2870///
2871/// # Arguments
2872/// * `value` - Integer value to encode
2873/// * `bits` - Bit width of field (16, 32, or 64)
2874/// * `signed` - Whether the field is signed (true) or unsigned (false)
2875///
2876/// # Returns
2877/// A vector of bytes containing the encoded binary integer
2878///
2879/// # Errors
2880/// Returns an error if the value is out of range for the specified bit width.
2881///
2882/// # Examples
2883///
2884/// ## 16-bit Signed Integer
2885///
2886/// ```no_run
2887/// use copybook_codec::numeric::{encode_binary_int};
2888///
2889/// // Encode -12345 as 16-bit signed
2890/// let encoded = encode_binary_int(-12345, 16, true)?;
2891/// assert_eq!(encoded, [0xCF, 0xC7]); // Big-endian
2892/// # Ok::<(), copybook_core::Error>(())
2893/// ```
2894///
2895/// ## 16-bit Unsigned Integer
2896///
2897/// ```no_run
2898/// use copybook_codec::numeric::{encode_binary_int};
2899///
2900/// // Encode 54321 as 16-bit unsigned
2901/// let encoded = encode_binary_int(54321, 16, false)?;
2902/// assert_eq!(encoded, [0xD4, 0x31]);
2903/// # Ok::<(), copybook_core::Error>(())
2904/// ```
2905///
2906/// ## 32-bit Signed Integer
2907///
2908/// ```no_run
2909/// use copybook_codec::numeric::{encode_binary_int};
2910///
2911/// // Encode -987654321 as 32-bit signed
2912/// let encoded = encode_binary_int(-987654321, 32, true)?;
2913/// assert_eq!(encoded, [0xC5, 0x7D, 0x3C, 0x21]);
2914/// # Ok::<(), copybook_core::Error>(())
2915/// ```
2916///
2917/// ## 64-bit Signed Integer
2918///
2919/// ```no_run
2920/// use copybook_codec::numeric::{encode_binary_int};
2921///
2922/// // Encode 9223372036854775807 as 64-bit signed
2923/// let encoded = encode_binary_int(9223372036854775807, 64, true)?;
2924/// assert_eq!(encoded, [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07]);
2925/// # Ok::<(), copybook_core::Error>(())
2926/// ```
2927///
2928/// # See Also
2929/// * [`decode_binary_int`] - For decoding binary integers
2930/// * [`get_binary_width_from_digits`] - For mapping digit count to width
2931/// * [`validate_explicit_binary_width`] - For validating explicit BINARY(n) widths
2932#[inline]
2933#[must_use = "Handle the Result or propagate the error"]
2934pub fn encode_binary_int(value: i64, bits: u16, signed: bool) -> Result<Vec<u8>> {
2935 match bits {
2936 16 => {
2937 if signed {
2938 let int_value = i16::try_from(value).map_err(|_| {
2939 Error::new(
2940 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2941 format!("Value {value} out of range for signed 16-bit integer"),
2942 )
2943 })?;
2944 Ok(int_value.to_be_bytes().to_vec())
2945 } else {
2946 let int_value = u16::try_from(value).map_err(|_| {
2947 Error::new(
2948 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2949 format!("Value {value} out of range for unsigned 16-bit integer"),
2950 )
2951 })?;
2952 Ok(int_value.to_be_bytes().to_vec())
2953 }
2954 }
2955 32 => {
2956 if signed {
2957 let int_value = i32::try_from(value).map_err(|_| {
2958 Error::new(
2959 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2960 format!("Value {value} out of range for signed 32-bit integer"),
2961 )
2962 })?;
2963 Ok(int_value.to_be_bytes().to_vec())
2964 } else {
2965 let int_value = u32::try_from(value).map_err(|_| {
2966 Error::new(
2967 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2968 format!("Value {value} out of range for unsigned 32-bit integer"),
2969 )
2970 })?;
2971 Ok(int_value.to_be_bytes().to_vec())
2972 }
2973 }
2974 64 => {
2975 if signed {
2976 Ok(value.to_be_bytes().to_vec())
2977 } else {
2978 let int_value = u64::try_from(value).map_err(|_| {
2979 Error::new(
2980 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2981 format!("Value {value} cannot be negative for unsigned 64-bit integer"),
2982 )
2983 })?;
2984 Ok(int_value.to_be_bytes().to_vec())
2985 }
2986 }
2987 _ => Err(Error::new(
2988 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2989 format!("Unsupported binary field width: {bits} bits"),
2990 )),
2991 }
2992}
2993
2994/// Encode an alphanumeric (PIC X) field with right-padding to the declared length.
2995///
2996/// Converts the UTF-8 input string to the target codepage encoding and then
2997/// right-pads with space characters (ASCII `0x20` or EBCDIC `0x40`) to fill
2998/// the declared field length.
2999///
3000/// # Arguments
3001/// * `text` - UTF-8 string value to encode
3002/// * `field_len` - Declared byte length of the COBOL field
3003/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
3004///
3005/// # Returns
3006/// A vector of exactly `field_len` bytes containing the encoded and padded text.
3007///
3008/// # Errors
3009/// * `CBKE501_JSON_TYPE_MISMATCH` - if the encoded text exceeds `field_len` bytes
3010///
3011/// # Examples
3012///
3013/// ```
3014/// use copybook_codec::numeric::encode_alphanumeric;
3015/// use copybook_codec::Codepage;
3016///
3017/// // Encode a 5-character string into a 10-byte ASCII field (right-padded with spaces)
3018/// let result = encode_alphanumeric("HELLO", 10, Codepage::ASCII).unwrap();
3019/// assert_eq!(result.len(), 10);
3020/// assert_eq!(&result[..5], b"HELLO");
3021/// assert_eq!(&result[5..], b" "); // padded with spaces
3022/// ```
3023///
3024/// # See Also
3025/// * [`crate::charset::utf8_to_ebcdic`] - Underlying character conversion
3026#[inline]
3027#[must_use = "Handle the Result or propagate the error"]
3028pub fn encode_alphanumeric(text: &str, field_len: usize, codepage: Codepage) -> Result<Vec<u8>> {
3029 // Convert UTF-8 to target encoding
3030 let encoded_bytes = crate::charset::utf8_to_ebcdic(text, codepage)?;
3031
3032 if encoded_bytes.len() > field_len {
3033 return Err(Error::new(
3034 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
3035 format!(
3036 "Text length {} exceeds field length {}",
3037 encoded_bytes.len(),
3038 field_len
3039 ),
3040 ));
3041 }
3042
3043 // Pad with spaces to field length (NORMATIVE)
3044 let mut result = encoded_bytes;
3045 let space_byte = match codepage {
3046 Codepage::ASCII => b' ',
3047 _ => 0x40, // EBCDIC space
3048 };
3049
3050 result.resize(field_len, space_byte);
3051 Ok(result)
3052}
3053
3054/// Determine whether a value should be encoded as all spaces under the
3055/// COBOL `BLANK WHEN ZERO` clause.
3056///
3057/// Returns `true` when `bwz_encode` is enabled **and** the string value
3058/// represents zero (including decimal zeros such as `"0.00"`). Callers
3059/// use this check before encoding to decide whether to emit a
3060/// space-filled field instead of the normal numeric encoding.
3061///
3062/// # Arguments
3063/// * `value` - String representation of the numeric value to test
3064/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active for this field
3065///
3066/// # Returns
3067/// `true` if the field should be encoded as all spaces; `false` otherwise.
3068///
3069/// # Examples
3070///
3071/// ```
3072/// use copybook_codec::numeric::should_encode_as_blank_when_zero;
3073///
3074/// assert!(should_encode_as_blank_when_zero("0", true));
3075/// assert!(should_encode_as_blank_when_zero("0.00", true));
3076/// assert!(!should_encode_as_blank_when_zero("42", true));
3077/// assert!(!should_encode_as_blank_when_zero("0", false)); // BWZ disabled
3078/// ```
3079///
3080/// # See Also
3081/// * [`encode_zoned_decimal_with_bwz`] - Uses this function to apply the BWZ policy
3082#[inline]
3083#[must_use]
3084pub fn should_encode_as_blank_when_zero(value: &str, bwz_encode: bool) -> bool {
3085 if !bwz_encode {
3086 return false;
3087 }
3088
3089 // Check if value is zero (with any scale)
3090 let trimmed = value.trim();
3091 if trimmed.is_empty() || trimmed == "0" {
3092 return true;
3093 }
3094
3095 // Check for decimal zero (0.00, 0.000, etc.)
3096 if let Some(dot_pos) = trimmed.find('.') {
3097 let integer_part = &trimmed[..dot_pos];
3098 let fractional_part = &trimmed[dot_pos + 1..];
3099
3100 if integer_part == "0" && fractional_part.chars().all(|c| c == '0') {
3101 return true;
3102 }
3103 }
3104
3105 false
3106}
3107
3108/// Encode a zoned decimal with COBOL `BLANK WHEN ZERO` support.
3109///
3110/// When `bwz_encode` is `true` and the value is zero (including decimal zeros
3111/// like `"0.00"`), the entire field is filled with space bytes (ASCII `0x20`
3112/// or EBCDIC `0x40`). Otherwise, encoding delegates to [`encode_zoned_decimal`].
3113///
3114/// # Arguments
3115/// * `value` - String representation of the decimal value to encode
3116/// * `digits` - Number of digit positions in the COBOL field
3117/// * `scale` - Number of implied decimal places
3118/// * `signed` - Whether the field carries a sign
3119/// * `codepage` - Target character encoding
3120/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active
3121///
3122/// # Returns
3123/// A vector of bytes containing the encoded zoned decimal, or all-space bytes
3124/// when the BWZ policy triggers.
3125///
3126/// # Errors
3127/// Returns an error if the value cannot be represented in the target zoned
3128/// decimal format (delegates error handling to [`encode_zoned_decimal`]).
3129///
3130/// # See Also
3131/// * [`should_encode_as_blank_when_zero`] - The predicate used to test zero values
3132/// * [`encode_zoned_decimal`] - Non-BWZ zoned decimal encoding
3133#[inline]
3134#[must_use = "Handle the Result or propagate the error"]
3135pub fn encode_zoned_decimal_with_bwz(
3136 value: &str,
3137 digits: u16,
3138 scale: i16,
3139 signed: bool,
3140 codepage: Codepage,
3141 bwz_encode: bool,
3142) -> Result<Vec<u8>> {
3143 // Check BWZ policy first
3144 if should_encode_as_blank_when_zero(value, bwz_encode) {
3145 let space_byte = match codepage {
3146 Codepage::ASCII => b' ',
3147 _ => 0x40, // EBCDIC space
3148 };
3149 return Ok(vec![space_byte; usize::from(digits)]);
3150 }
3151
3152 encode_zoned_decimal(value, digits, scale, signed, codepage)
3153}
3154
3155/// Map a COBOL PIC digit count to the corresponding USAGE BINARY storage width
3156/// in bits (NORMATIVE).
3157///
3158/// Follows the COBOL standard mapping:
3159/// - 1-4 digits -> 16 bits (2 bytes, halfword)
3160/// - 5-9 digits -> 32 bits (4 bytes, fullword)
3161/// - 10-18 digits -> 64 bits (8 bytes, doubleword)
3162///
3163/// # Arguments
3164/// * `digits` - Number of digit positions declared in the PIC clause
3165///
3166/// # Returns
3167/// The storage width in bits: 16, 32, or 64.
3168///
3169/// # See Also
3170/// * [`validate_explicit_binary_width`] - For explicit `USAGE BINARY(n)` declarations
3171/// * [`decode_binary_int`] - Decodes binary integer data at the determined width
3172#[inline]
3173#[must_use]
3174pub fn get_binary_width_from_digits(digits: u16) -> u16 {
3175 match digits {
3176 1..=4 => 16, // 2 bytes
3177 5..=9 => 32, // 4 bytes
3178 _ => 64, // 8 bytes for larger values
3179 }
3180}
3181
3182/// Validate an explicit `USAGE BINARY(n)` byte-width declaration and return
3183/// the equivalent bit width (NORMATIVE).
3184///
3185/// Only the widths 1, 2, 4, and 8 bytes are valid. Each is converted to
3186/// the corresponding bit count (8, 16, 32, 64) for downstream codec use.
3187///
3188/// # Arguments
3189/// * `width_bytes` - The explicit byte width from the copybook (1, 2, 4, or 8)
3190///
3191/// # Returns
3192/// The equivalent width in bits: 8, 16, 32, or 64.
3193///
3194/// # Errors
3195/// * `CBKE501_JSON_TYPE_MISMATCH` - if `width_bytes` is not 1, 2, 4, or 8
3196///
3197/// # See Also
3198/// * [`get_binary_width_from_digits`] - Infers width from PIC digit count
3199#[inline]
3200#[must_use = "Handle the Result or propagate the error"]
3201pub fn validate_explicit_binary_width(width_bytes: u8) -> Result<u16> {
3202 match width_bytes {
3203 1 => Ok(8), // 1 byte = 8 bits
3204 2 => Ok(16), // 2 bytes = 16 bits
3205 4 => Ok(32), // 4 bytes = 32 bits
3206 8 => Ok(64), // 8 bytes = 64 bits
3207 _ => Err(Error::new(
3208 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
3209 format!("Invalid explicit binary width: {width_bytes} bytes. Must be 1, 2, 4, or 8"),
3210 )),
3211 }
3212}
3213
3214/// Get the space byte value for a given codepage
3215///
3216/// Returns the appropriate space character byte for ASCII or EBCDIC codepages.
3217///
3218/// # Arguments
3219/// * `codepage` - The target codepage
3220///
3221/// # Returns
3222/// * `0x20` (ASCII space) for ASCII codepage
3223/// * `0x40` (EBCDIC space) for EBCDIC codepages
3224///
3225/// # Examples
3226/// ```
3227/// use copybook_codec::options::Codepage;
3228/// # fn zoned_space_byte(codepage: Codepage) -> u8 {
3229/// # match codepage {
3230/// # Codepage::ASCII => b' ',
3231/// # _ => 0x40,
3232/// # }
3233/// # }
3234///
3235/// assert_eq!(zoned_space_byte(Codepage::ASCII), b' ');
3236/// assert_eq!(zoned_space_byte(Codepage::CP037), 0x40);
3237/// ```
3238#[inline]
3239const fn zoned_space_byte(codepage: Codepage) -> u8 {
3240 match codepage {
3241 Codepage::ASCII => b' ',
3242 _ => 0x40,
3243 }
3244}
3245
3246/// Get the expected zone nibble for valid digits
3247///
3248/// Returns the zone nibble value expected for digit bytes in zoned decimal
3249/// encoding for the given codepage.
3250///
3251/// # Arguments
3252/// * `codepage` - The target codepage
3253///
3254/// # Returns
3255/// * `0x3` for ASCII (digits 0x30-0x39)
3256/// * `0xF` for EBCDIC (digits 0xF0-0xF9)
3257///
3258/// # Examples
3259/// ```
3260/// use copybook_codec::options::Codepage;
3261/// # const ASCII_DIGIT_ZONE: u8 = 0x3;
3262/// # const EBCDIC_DIGIT_ZONE: u8 = 0xF;
3263/// # fn zoned_expected_zone(codepage: Codepage) -> u8 {
3264/// # match codepage {
3265/// # Codepage::ASCII => ASCII_DIGIT_ZONE,
3266/// # _ => EBCDIC_DIGIT_ZONE,
3267/// # }
3268/// # }
3269///
3270/// assert_eq!(zoned_expected_zone(Codepage::ASCII), 0x3);
3271/// assert_eq!(zoned_expected_zone(Codepage::CP037), 0xF);
3272/// ```
3273#[inline]
3274const fn zoned_expected_zone(codepage: Codepage) -> u8 {
3275 match codepage {
3276 Codepage::ASCII => ASCII_DIGIT_ZONE,
3277 _ => EBCDIC_DIGIT_ZONE,
3278 }
3279}
3280
3281/// Get a human-readable label for the encoding zone type
3282///
3283/// Returns a string label describing the encoding zone type for error messages.
3284///
3285/// # Arguments
3286/// * `codepage` - The target codepage
3287///
3288/// # Returns
3289/// * `"ASCII"` for ASCII codepage
3290/// * `"EBCDIC"` for EBCDIC codepages
3291///
3292/// # Examples
3293/// ```
3294/// use copybook_codec::options::Codepage;
3295/// # fn zoned_zone_label(codepage: Codepage) -> &'static str {
3296/// # match codepage {
3297/// # Codepage::ASCII => "ASCII",
3298/// # _ => "EBCDIC",
3299/// # }
3300/// # }
3301///
3302/// assert_eq!(zoned_zone_label(Codepage::ASCII), "ASCII");
3303/// assert_eq!(zoned_zone_label(Codepage::CP037), "EBCDIC");
3304/// ```
3305#[inline]
3306const fn zoned_zone_label(codepage: Codepage) -> &'static str {
3307 match codepage {
3308 Codepage::ASCII => "ASCII",
3309 _ => "EBCDIC",
3310 }
3311}
3312
3313/// Validate a non-final byte in a zoned decimal field
3314///
3315/// Checks that the byte contains a valid digit nibble (0-9) and the expected
3316/// zone nibble for the codepage. Non-final bytes should not contain sign information.
3317///
3318/// # Arguments
3319/// * `byte` - The byte to validate
3320/// * `index` - Position of the byte in the field (for error messages)
3321/// * `expected_zone` - Expected zone nibble value (0x3 for ASCII, 0xF for EBCDIC)
3322/// * `codepage` - Target codepage for zone validation
3323///
3324/// # Returns
3325/// The digit nibble value (0-9) extracted from the byte
3326///
3327/// # Errors
3328/// * `CBKD411_ZONED_BAD_SIGN` - Invalid digit nibble or mismatched zone
3329///
3330/// # Examples
3331/// ```text
3332/// // ASCII '5' is 0x35 (zone 0x3, digit 0x5)
3333/// let digit = zoned_validate_non_final_byte(0x35, 0, 0x3, Codepage::ASCII)?;
3334/// assert_eq!(digit, 5);
3335/// ```
3336#[inline]
3337fn zoned_validate_non_final_byte(
3338 byte: u8,
3339 index: usize,
3340 expected_zone: u8,
3341 codepage: Codepage,
3342) -> Result<u8> {
3343 let zone = (byte >> 4) & 0x0F;
3344 let digit = byte & 0x0F;
3345
3346 if digit > 9 {
3347 return Err(Error::new(
3348 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3349 format!("Invalid digit nibble 0x{digit:X} at position {index}"),
3350 ));
3351 }
3352
3353 if zone != expected_zone {
3354 let zone_label = zoned_zone_label(codepage);
3355 return Err(Error::new(
3356 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3357 format!(
3358 "Invalid {zone_label} zone 0x{zone:X} at position {index}, expected 0x{expected_zone:X}"
3359 ),
3360 ));
3361 }
3362
3363 Ok(digit)
3364}
3365
3366/// Process all non-final digits in a zoned decimal field
3367///
3368/// Validates each byte's zone and digit nibbles, accumulates the numeric value,
3369/// and stores digits in the scratch buffer for verification.
3370///
3371/// # Arguments
3372/// * `data` - Non-final bytes of the zoned decimal field
3373/// * `expected_zone` - Expected zone nibble (0x3 for ASCII, 0xF for EBCDIC)
3374/// * `codepage` - Target codepage
3375/// * `scratch` - Scratch buffers for digit accumulation
3376///
3377/// # Returns
3378/// Accumulated integer value from non-final digits
3379///
3380/// # Errors
3381/// * `CBKD411_ZONED_BAD_SIGN` - Invalid zone or digit nibble encountered
3382///
3383/// # Performance
3384/// Uses saturating arithmetic to prevent overflow panics while accumulating
3385/// the numeric value.
3386#[inline]
3387fn zoned_process_non_final_digits(
3388 data: &[u8],
3389 expected_zone: u8,
3390 codepage: Codepage,
3391 scratch: &mut ScratchBuffers,
3392) -> Result<i64> {
3393 let mut value = 0i64;
3394
3395 for (index, &byte) in data.iter().enumerate() {
3396 let digit = zoned_validate_non_final_byte(byte, index, expected_zone, codepage)?;
3397 scratch.digit_buffer.push(digit);
3398 value = value.saturating_mul(10).saturating_add(i64::from(digit));
3399 }
3400
3401 Ok(value)
3402}
3403
3404/// Decode the last byte of a zoned decimal field
3405///
3406/// The last byte contains both a digit and sign information encoded as an
3407/// overpunch character. Delegates to the overpunch decoder for extraction.
3408///
3409/// # Arguments
3410/// * `byte` - The final byte of the zoned decimal field
3411/// * `codepage` - Target codepage for overpunch interpretation
3412///
3413/// # Returns
3414/// Tuple of (digit, `is_negative`) extracted from the overpunch byte
3415///
3416/// # Errors
3417/// * `CBKD411_ZONED_BAD_SIGN` - Invalid overpunch encoding
3418///
3419/// # See Also
3420/// * `zoned_overpunch::decode_overpunch_byte` - Underlying overpunch decoder
3421#[inline]
3422fn zoned_decode_last_byte(byte: u8, codepage: Codepage) -> Result<(u8, bool)> {
3423 crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)
3424}
3425
3426/// Ensure unsigned zoned decimal has no sign information
3427///
3428/// Validates that an unsigned zoned decimal field contains only unsigned zone
3429/// nibbles and no negative overpunch encoding.
3430///
3431/// # Arguments
3432/// * `last_byte` - The final byte of the field
3433/// * `expected_zone` - Expected unsigned zone (0x3 for ASCII, 0xF for EBCDIC)
3434/// * `codepage` - Target codepage
3435/// * `negative` - Whether overpunch decoding detected a negative sign
3436///
3437/// # Returns
3438/// Always returns `Ok(false)` for valid unsigned fields
3439///
3440/// # Errors
3441/// * `CBKD411_ZONED_BAD_SIGN` - Sign zone or negative overpunch in unsigned field
3442///
3443/// # Examples
3444/// ```text
3445/// // Valid unsigned ASCII zoned decimal ends with zone 0x3
3446/// let result = zoned_ensure_unsigned(0x35, 0x3, Codepage::ASCII, false)?;
3447/// assert_eq!(result, false);
3448/// ```
3449#[inline]
3450fn zoned_ensure_unsigned(
3451 last_byte: u8,
3452 expected_zone: u8,
3453 codepage: Codepage,
3454 negative: bool,
3455) -> Result<bool> {
3456 let zone = (last_byte >> 4) & 0x0F;
3457 if zone != expected_zone {
3458 let zone_label = zoned_zone_label(codepage);
3459 return Err(Error::new(
3460 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3461 format!(
3462 "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
3463 ),
3464 ));
3465 }
3466
3467 if negative {
3468 return Err(Error::new(
3469 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3470 "Unsigned zoned decimal contains negative overpunch",
3471 ));
3472 }
3473
3474 Ok(false)
3475}
3476
3477/// Decode a zoned decimal using the configured code page and policy while reusing scratch buffers.
3478///
3479/// Decodes zoned decimal fields while reusing scratch buffers to avoid repeated allocations.
3480/// This is optimized for high-throughput processing where the same scratch buffers
3481/// are used across multiple decode operations.
3482///
3483/// # Arguments
3484/// * `data` - Raw byte data containing the zoned decimal
3485/// * `digits` - Number of digit characters (field length)
3486/// * `scale` - Number of decimal places (can be negative for scaling)
3487/// * `signed` - Whether the field is signed (true) or unsigned (false)
3488/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
3489/// * `blank_when_zero` - If true, all-space fields decode as zero
3490/// * `scratch` - Mutable reference to scratch buffers for reuse
3491///
3492/// # Returns
3493/// A `SmallDecimal` containing of decoded value
3494///
3495/// # Policy
3496/// Defaults to *preferred zero sign* (`ZeroSignPolicy::Preferred`) for EBCDIC zeros unless
3497/// `preserve_zoned_encoding` captured an explicit format at decode.
3498///
3499/// # Errors
3500/// Returns an error if zone nibbles or the last-byte overpunch are invalid.
3501///
3502/// # Performance
3503/// This function avoids allocations by reusing scratch buffers across decode operations.
3504/// Use this for processing multiple zoned decimal fields in a loop.
3505///
3506/// # Examples
3507///
3508/// ## Basic Decoding
3509///
3510/// ```no_run
3511/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
3512/// use copybook_codec::memory::ScratchBuffers;
3513/// use copybook_codec::options::Codepage;
3514///
3515/// let mut scratch = ScratchBuffers::new();
3516/// let data = b"123";
3517/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, false, &mut scratch)?;
3518/// assert_eq!(result.to_string(), "123");
3519/// # Ok::<(), copybook_core::Error>(())
3520/// ```
3521///
3522/// ## With BLANK WHEN ZERO
3523///
3524/// ```no_run
3525/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
3526/// use copybook_codec::memory::ScratchBuffers;
3527/// use copybook_codec::options::Codepage;
3528///
3529/// let mut scratch = ScratchBuffers::new();
3530/// let data = b" "; // 3 ASCII spaces
3531/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, true, &mut scratch)?;
3532/// assert_eq!(result.to_string(), "0");
3533/// # Ok::<(), copybook_core::Error>(())
3534/// ```
3535///
3536/// # See Also
3537/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
3538/// * [`ScratchBuffers`] - For scratch buffer management
3539#[inline]
3540#[must_use = "Handle the Result or propagate the error"]
3541pub fn decode_zoned_decimal_with_scratch(
3542 data: &[u8],
3543 digits: u16,
3544 scale: i16,
3545 signed: bool,
3546 codepage: Codepage,
3547 blank_when_zero: bool,
3548 scratch: &mut ScratchBuffers,
3549) -> Result<SmallDecimal> {
3550 if data.len() != usize::from(digits) {
3551 return Err(Error::new(
3552 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3553 format!(
3554 "Zoned decimal data length {} doesn't match digits {}",
3555 data.len(),
3556 digits
3557 ),
3558 ));
3559 }
3560
3561 // Check for BLANK WHEN ZERO (all spaces) - optimized check
3562 let space_byte = zoned_space_byte(codepage);
3563
3564 let is_all_spaces = data.iter().all(|&b| b == space_byte);
3565 if is_all_spaces {
3566 if blank_when_zero {
3567 warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
3568 crate::lib_api::increment_warning_counter();
3569 return Ok(SmallDecimal::zero(scale));
3570 }
3571 return Err(Error::new(
3572 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3573 "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
3574 ));
3575 }
3576
3577 // Clear and prepare digit buffer for reuse
3578 scratch.digit_buffer.clear();
3579 scratch.digit_buffer.reserve(usize::from(digits));
3580
3581 let expected_zone = zoned_expected_zone(codepage);
3582 let Some((&last_byte, non_final)) = data.split_last() else {
3583 return Err(Error::new(
3584 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3585 "Zoned decimal field is empty",
3586 ));
3587 };
3588 let partial_value =
3589 zoned_process_non_final_digits(non_final, expected_zone, codepage, scratch)?;
3590 let (last_digit, negative) = zoned_decode_last_byte(last_byte, codepage)?;
3591 scratch.digit_buffer.push(last_digit);
3592 let value = partial_value
3593 .saturating_mul(10)
3594 .saturating_add(i64::from(last_digit));
3595 let is_negative = if signed {
3596 negative
3597 } else {
3598 zoned_ensure_unsigned(last_byte, expected_zone, codepage, negative)?
3599 };
3600 let mut decimal = SmallDecimal::new(value, scale, is_negative);
3601 decimal.normalize();
3602
3603 debug_assert!(
3604 scratch.digit_buffer.iter().all(|&d| d <= 9),
3605 "scratch digit buffer must contain only logical digits"
3606 );
3607 Ok(decimal)
3608}
3609
3610/// Decode a single-byte packed decimal value
3611///
3612/// Handles the special case where the entire packed decimal fits in one byte.
3613/// For 1-digit fields, the high nibble contains the digit and low nibble contains
3614/// the sign. For 0-digit fields (just sign), only the low nibble is significant.
3615///
3616/// # Arguments
3617/// * `byte` - The packed decimal byte
3618/// * `digits` - Number of digits (0 or 1 for single byte)
3619/// * `scale` - Decimal scale
3620/// * `signed` - Whether the field is signed
3621///
3622/// # Returns
3623/// Decoded `SmallDecimal` value
3624///
3625/// # Errors
3626/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
3627///
3628/// # Format
3629/// Single-byte packed decimals:
3630/// - 1 digit: `[digit][sign]` (e.g., 0x5C = 5 positive)
3631/// - 0 digits: `[0][sign]` (just sign, high nibble must be 0)
3632///
3633/// Valid sign nibbles:
3634/// - Positive: 0xA, 0xC, 0xE, 0xF
3635/// - Negative: 0xB, 0xD
3636/// - Unsigned: 0xF only
3637#[inline]
3638fn packed_decode_single_byte(
3639 byte: u8,
3640 digits: u16,
3641 scale: i16,
3642 signed: bool,
3643) -> Result<SmallDecimal> {
3644 let high_nibble = (byte >> 4) & 0x0F;
3645 let low_nibble = byte & 0x0F;
3646 let mut value = 0i64;
3647
3648 if digits == 1 {
3649 if high_nibble > 9 {
3650 return Err(Error::new(
3651 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3652 format!("Invalid digit nibble 0x{high_nibble:X}"),
3653 ));
3654 }
3655 value = i64::from(high_nibble);
3656 }
3657
3658 let is_negative = if signed {
3659 match low_nibble {
3660 0xA | 0xC | 0xE | 0xF => false,
3661 0xB | 0xD => true,
3662 _ => {
3663 return Err(Error::new(
3664 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3665 format!("Invalid sign nibble 0x{low_nibble:X}"),
3666 ));
3667 }
3668 }
3669 } else {
3670 if low_nibble != 0xF {
3671 return Err(Error::new(
3672 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3673 format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
3674 ));
3675 }
3676 false
3677 };
3678
3679 Ok(create_normalized_decimal(value, scale, is_negative))
3680}
3681
3682/// Add a digit to the accumulating packed decimal value
3683///
3684/// Multiplies the current value by 10 and adds the new digit, with overflow checking.
3685///
3686/// # Arguments
3687/// * `value` - Mutable reference to the accumulating value
3688/// * `digit` - Digit to add (0-9)
3689///
3690/// # Returns
3691/// `Ok(())` on success
3692///
3693/// # Errors
3694/// * `CBKD411_ZONED_BAD_SIGN` - Numeric overflow during accumulation
3695///
3696/// # Performance
3697/// Uses checked arithmetic to prevent panics while detecting overflow conditions.
3698#[inline]
3699fn packed_push_digit(value: &mut i64, digit: u8) -> Result<()> {
3700 *value = value
3701 .checked_mul(10)
3702 .and_then(|v| v.checked_add(i64::from(digit)))
3703 .ok_or_else(|| {
3704 Error::new(
3705 ErrorCode::CBKD411_ZONED_BAD_SIGN,
3706 "Numeric overflow during zoned decimal conversion",
3707 )
3708 })?;
3709 Ok(())
3710}
3711
3712/// Process non-final bytes of a multi-byte packed decimal
3713///
3714/// Extracts digit nibbles from all bytes before the last one, handling padding
3715/// if the digit count is odd. Accumulates the numeric value and counts digits.
3716///
3717/// # Arguments
3718/// * `bytes` - Non-final bytes of the packed decimal
3719/// * `digits` - Total number of digits in the field
3720/// * `has_padding` - Whether the first nibble is padding (odd total nibbles)
3721///
3722/// # Returns
3723/// Tuple of (`accumulated_value`, `digit_count`)
3724///
3725/// # Errors
3726/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or padding nibble
3727///
3728/// # Format
3729/// Packed decimal nibble layout:
3730/// - Even digits: `[pad=0][d1][d2][d3]...[sign]`
3731/// - Odd digits: `[d1][d2][d3]...[sign]` (no padding)
3732#[inline]
3733fn packed_process_non_last_bytes(
3734 bytes: &[u8],
3735 digits: u16,
3736 has_padding: bool,
3737) -> Result<(i64, u16)> {
3738 let mut value = 0i64;
3739 let mut digit_count: u16 = 0;
3740
3741 for (index, &byte) in bytes.iter().enumerate() {
3742 let high_nibble = (byte >> 4) & 0x0F;
3743 let low_nibble = byte & 0x0F;
3744
3745 if index == 0 && has_padding {
3746 if high_nibble != 0 {
3747 return Err(Error::new(
3748 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3749 format!("Expected padding nibble 0, got 0x{high_nibble:X}"),
3750 ));
3751 }
3752 } else {
3753 if high_nibble > 9 {
3754 return Err(Error::new(
3755 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3756 format!("Invalid digit nibble 0x{high_nibble:X}"),
3757 ));
3758 }
3759 packed_push_digit(&mut value, high_nibble)?;
3760 digit_count += 1;
3761 }
3762
3763 if digit_count >= digits {
3764 break;
3765 }
3766
3767 if low_nibble > 9 {
3768 return Err(Error::new(
3769 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3770 format!("Invalid digit nibble 0x{low_nibble:X}"),
3771 ));
3772 }
3773 packed_push_digit(&mut value, low_nibble)?;
3774 digit_count += 1;
3775
3776 if digit_count >= digits {
3777 break;
3778 }
3779 }
3780
3781 Ok((value, digit_count))
3782}
3783
3784/// Process the last byte of a packed decimal field
3785///
3786/// Extracts the final digit (if needed) and sign nibble from the last byte.
3787/// Creates the final normalized `SmallDecimal` value.
3788///
3789/// # Arguments
3790/// * `value` - Accumulated value from previous bytes
3791/// * `last_byte` - The final byte containing digit and sign
3792/// * `digits` - Total number of digits expected
3793/// * `digit_count` - Number of digits already processed
3794/// * `scale` - Decimal scale
3795/// * `signed` - Whether the field is signed
3796///
3797/// # Returns
3798/// Decoded and normalized `SmallDecimal`
3799///
3800/// # Errors
3801/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
3802///
3803/// # Format
3804/// Last byte always ends with sign nibble:
3805/// - If `digit_count` < digits: `[digit][sign]`
3806/// - If `digit_count` == digits: `[unused][sign]` (high nibble ignored)
3807#[inline]
3808fn packed_finish_last_byte(
3809 mut value: i64,
3810 last_byte: u8,
3811 digits: u16,
3812 digit_count: u16,
3813 scale: i16,
3814 signed: bool,
3815) -> Result<SmallDecimal> {
3816 let high_nibble = (last_byte >> 4) & 0x0F;
3817 let low_nibble = last_byte & 0x0F;
3818
3819 if digit_count < digits {
3820 if high_nibble > 9 {
3821 return Err(Error::new(
3822 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3823 format!("Invalid digit nibble 0x{high_nibble:X}"),
3824 ));
3825 }
3826 packed_push_digit(&mut value, high_nibble)?;
3827 }
3828
3829 let is_negative = if signed {
3830 match low_nibble {
3831 0xA | 0xC | 0xE | 0xF => false,
3832 0xB | 0xD => true,
3833 _ => {
3834 return Err(Error::new(
3835 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3836 format!("Invalid sign nibble 0x{low_nibble:X}"),
3837 ));
3838 }
3839 }
3840 } else {
3841 if low_nibble != 0xF {
3842 return Err(Error::new(
3843 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3844 format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
3845 ));
3846 }
3847 false
3848 };
3849
3850 Ok(create_normalized_decimal(value, scale, is_negative))
3851}
3852
3853/// Decode a multi-byte packed decimal value
3854///
3855/// Orchestrates the decoding of packed decimals that span multiple bytes by
3856/// processing non-final bytes and the final byte separately.
3857///
3858/// # Arguments
3859/// * `data` - Complete packed decimal byte array
3860/// * `digits` - Number of digits in the field
3861/// * `scale` - Decimal scale
3862/// * `signed` - Whether the field is signed
3863///
3864/// # Returns
3865/// Decoded `SmallDecimal` value
3866///
3867/// # Errors
3868/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid nibbles or empty input
3869///
3870/// # Algorithm
3871/// 1. Calculate if padding nibble is present (odd total nibbles)
3872/// 2. Process all non-final bytes to extract digits
3873/// 3. Process final byte to extract last digit and sign
3874/// 4. Construct normalized `SmallDecimal`
3875#[inline]
3876fn packed_decode_multi_byte(
3877 data: &[u8],
3878 digits: u16,
3879 scale: i16,
3880 signed: bool,
3881) -> Result<SmallDecimal> {
3882 let total_nibbles = digits + 1;
3883 let has_padding = (total_nibbles & 1) == 1;
3884 let Some((&last_byte, non_last)) = data.split_last() else {
3885 return Err(Error::new(
3886 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3887 "Packed decimal input is empty",
3888 ));
3889 };
3890 let (value, digit_count) = packed_process_non_last_bytes(non_last, digits, has_padding)?;
3891 packed_finish_last_byte(value, last_byte, digits, digit_count, scale, signed)
3892}
3893
3894/// Optimized packed decimal decoder using scratch buffers
3895/// Minimizes allocations by reusing digit buffer
3896///
3897/// # Errors
3898/// Returns an error when the packed decimal data has an invalid length or contains bad digit/sign nibbles.
3899#[inline]
3900#[must_use = "Handle the Result or propagate the error"]
3901pub fn decode_packed_decimal_with_scratch(
3902 data: &[u8],
3903 digits: u16,
3904 scale: i16,
3905 signed: bool,
3906 scratch: &mut ScratchBuffers,
3907) -> Result<SmallDecimal> {
3908 let expected_bytes = usize::from((digits + 1).div_ceil(2));
3909 if data.len() != expected_bytes {
3910 return Err(Error::new(
3911 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3912 format!(
3913 "Packed decimal data length {} doesn't match expected {} bytes for {} digits",
3914 data.len(),
3915 expected_bytes,
3916 digits
3917 ),
3918 ));
3919 }
3920
3921 if data.is_empty() {
3922 return Ok(SmallDecimal::zero(scale));
3923 }
3924
3925 // Use the original implementation - the "optimized" path actually hurts performance
3926 // Clear and prepare digit buffer for reuse
3927 scratch.digit_buffer.clear();
3928 scratch.digit_buffer.reserve(usize::from(digits));
3929
3930 // Optimized nibble processing - unify handling for multi-byte cases
3931 let decimal = if data.len() == 1 {
3932 packed_decode_single_byte(data[0], digits, scale, signed)?
3933 } else {
3934 packed_decode_multi_byte(data, digits, scale, signed)?
3935 };
3936
3937 debug_assert!(
3938 scratch.digit_buffer.iter().all(|&d| d <= 9),
3939 "scratch digit buffer must contain only logical digits"
3940 );
3941
3942 Ok(decimal)
3943}
3944
3945/// Decode a COBOL binary integer (USAGE BINARY / COMP) with optimized fast
3946/// paths for 16-, 32-, and 64-bit widths.
3947///
3948/// For the three common widths this function reads the big-endian bytes
3949/// directly into the native integer type, avoiding the generic loop in
3950/// [`decode_binary_int`]. Uncommon widths fall back to that generic
3951/// implementation.
3952///
3953/// # Arguments
3954/// * `data` - Raw big-endian byte data containing the binary integer
3955/// * `bits` - Expected field width in bits (16, 32, or 64 for fast path)
3956/// * `signed` - Whether the field is signed (PIC S9 COMP) or unsigned (PIC 9 COMP)
3957///
3958/// # Returns
3959/// The decoded integer value as `i64`.
3960///
3961/// # Errors
3962/// * `CBKD401_COMP3_INVALID_NIBBLE` - if an unsigned 64-bit value exceeds `i64::MAX`
3963/// * Delegates to [`decode_binary_int`] for unsupported widths, which may
3964/// return its own errors.
3965///
3966/// # See Also
3967/// * [`decode_binary_int`] - General-purpose binary integer decoder
3968/// * [`encode_binary_int`] - Binary integer encoder
3969/// * [`format_binary_int_to_string_with_scratch`] - Formats the decoded value to a string
3970#[inline]
3971#[must_use = "Handle the Result or propagate the error"]
3972pub fn decode_binary_int_fast(data: &[u8], bits: u16, signed: bool) -> Result<i64> {
3973 // Optimized paths for common binary widths
3974 match (bits, data.len()) {
3975 (16, 2) => {
3976 // 16-bit integer - most common case
3977 let bytes = [data[0], data[1]];
3978 if signed {
3979 Ok(i64::from(i16::from_be_bytes(bytes)))
3980 } else {
3981 Ok(i64::from(u16::from_be_bytes(bytes)))
3982 }
3983 }
3984 (32, 4) => {
3985 // 32-bit integer - common case
3986 let bytes = [data[0], data[1], data[2], data[3]];
3987 if signed {
3988 Ok(i64::from(i32::from_be_bytes(bytes)))
3989 } else {
3990 Ok(i64::from(u32::from_be_bytes(bytes)))
3991 }
3992 }
3993 (64, 8) => {
3994 // 64-bit integer
3995 let bytes = [
3996 data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
3997 ];
3998 if signed {
3999 Ok(i64::from_be_bytes(bytes))
4000 } else {
4001 let value = u64::from_be_bytes(bytes);
4002 let max_i64 = u64::try_from(i64::MAX).unwrap_or(u64::MAX);
4003 if value > max_i64 {
4004 return Err(Error::new(
4005 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4006 format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
4007 ));
4008 }
4009 i64::try_from(value).map_err(|_| {
4010 Error::new(
4011 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4012 format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
4013 )
4014 })
4015 }
4016 }
4017 _ => {
4018 // Fallback to general implementation
4019 decode_binary_int(data, bits, signed)
4020 }
4021 }
4022}
4023
4024/// Encode a zoned decimal while reusing caller-owned scratch buffers to avoid
4025/// per-call heap allocations on the hot path.
4026///
4027/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
4028/// the scratch buffer and then delegates to [`encode_zoned_decimal`]. The
4029/// `_bwz_encode` parameter is reserved for future BLANK WHEN ZERO integration
4030/// but is currently unused.
4031///
4032/// # Arguments
4033/// * `decimal` - Pre-parsed decimal value to encode
4034/// * `digits` - Number of digit positions in the COBOL field
4035/// * `signed` - Whether the field carries a sign
4036/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
4037/// * `_bwz_encode` - Reserved for BLANK WHEN ZERO support (currently unused)
4038/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
4039///
4040/// # Returns
4041/// A vector of bytes containing the encoded zoned decimal.
4042///
4043/// # Policy
4044/// Callers typically resolve policy using `zoned_encoding_override` → preserved
4045/// metadata → `preferred_zoned_encoding`, matching the documented library
4046/// behavior for zoned decimals.
4047///
4048/// # Errors
4049/// Returns an error when the decimal value cannot be represented with the
4050/// requested digit count or encoding format.
4051///
4052/// # See Also
4053/// * [`encode_zoned_decimal`] - Underlying encoder
4054/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
4055#[inline]
4056#[must_use = "Handle the Result or propagate the error"]
4057pub fn encode_zoned_decimal_with_scratch(
4058 decimal: &SmallDecimal,
4059 digits: u16,
4060 signed: bool,
4061 codepage: Codepage,
4062 _bwz_encode: bool,
4063 scratch: &mut ScratchBuffers,
4064) -> Result<Vec<u8>> {
4065 // Clear and prepare buffers
4066 scratch.digit_buffer.clear();
4067 scratch.byte_buffer.clear();
4068 scratch.byte_buffer.reserve(usize::from(digits));
4069
4070 // Convert decimal to string using scratch buffer
4071 scratch.string_buffer.clear();
4072 scratch.string_buffer.push_str(&decimal.to_string());
4073
4074 // Use the standard encode function but with optimized digit processing
4075 // This is a placeholder for now - the actual optimization would involve
4076 // rewriting the encode logic to use the scratch buffers
4077 encode_zoned_decimal(
4078 &scratch.string_buffer,
4079 digits,
4080 decimal.scale,
4081 signed,
4082 codepage,
4083 )
4084}
4085
4086/// Encode a packed decimal (COMP-3) while reusing caller-owned scratch buffers
4087/// to minimize per-call allocations.
4088///
4089/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
4090/// the scratch buffer and then delegates to [`encode_packed_decimal`]. Intended
4091/// for use on codec hot paths where many records are encoded sequentially with
4092/// the same [`ScratchBuffers`] instance.
4093///
4094/// # Arguments
4095/// * `decimal` - Pre-parsed decimal value to encode
4096/// * `digits` - Number of decimal digits in the field (1-18)
4097/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
4098/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
4099///
4100/// # Returns
4101/// A vector of bytes containing the encoded packed decimal (COMP-3 format).
4102///
4103/// # Errors
4104/// Returns an error when the decimal value cannot be encoded into the
4105/// requested packed representation (delegates to [`encode_packed_decimal`]).
4106///
4107/// # See Also
4108/// * [`encode_packed_decimal`] - Underlying packed decimal encoder
4109/// * [`encode_zoned_decimal_with_scratch`] - Scratch-based zoned decimal encoder
4110/// * [`decode_packed_decimal_to_string_with_scratch`] - Scratch-based decoder
4111#[inline]
4112#[must_use = "Handle the Result or propagate the error"]
4113pub fn encode_packed_decimal_with_scratch(
4114 decimal: &SmallDecimal,
4115 digits: u16,
4116 signed: bool,
4117 scratch: &mut ScratchBuffers,
4118) -> Result<Vec<u8>> {
4119 // Clear and prepare buffers
4120 scratch.digit_buffer.clear();
4121 scratch.byte_buffer.clear();
4122 let expected_bytes = usize::from((digits + 1).div_ceil(2));
4123 scratch.byte_buffer.reserve(expected_bytes);
4124
4125 // Convert decimal to string using scratch buffer
4126 scratch.string_buffer.clear();
4127 scratch.string_buffer.push_str(&decimal.to_string());
4128
4129 // Use the standard encode function but with optimized nibble processing
4130 // This is a placeholder for now - the actual optimization would involve
4131 // rewriting the encode logic to use the scratch buffers
4132 encode_packed_decimal(&scratch.string_buffer, digits, decimal.scale, signed)
4133}
4134
4135/// Decode a packed decimal (COMP-3) directly to a `String`, bypassing the
4136/// intermediate [`SmallDecimal`] allocation.
4137///
4138/// This is a critical performance optimization for COMP-3 JSON conversion.
4139/// By decoding nibbles and formatting the result in a single pass using the
4140/// caller-owned scratch buffer, it avoids the `SmallDecimal` -> `String`
4141/// allocation overhead that caused 94-96% throughput regression in COMP-3
4142/// processing benchmarks.
4143///
4144/// # Arguments
4145/// * `data` - Raw byte data containing the packed decimal (BCD with trailing sign nibble)
4146/// * `digits` - Number of decimal digits in the field (1-18)
4147/// * `scale` - Number of implied decimal places (can be negative for scaling)
4148/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
4149/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
4150/// `std::mem::take` and returned as the result string.
4151///
4152/// # Returns
4153/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
4154///
4155/// # Errors
4156/// * `CBKD401_COMP3_INVALID_NIBBLE` - if any data nibble is > 9 or the sign
4157/// nibble is invalid
4158///
4159/// # Performance
4160/// Includes a fast path for single-digit packed decimals (1 byte) and falls
4161/// back to [`decode_packed_decimal_with_scratch`] plus
4162/// [`SmallDecimal::format_to_scratch_buffer`] for larger values.
4163///
4164/// # See Also
4165/// * [`decode_packed_decimal`] - Returns a `SmallDecimal` instead of a string
4166/// * [`decode_packed_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
4167/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
4168#[inline]
4169#[must_use = "Handle the Result or propagate the error"]
4170pub fn decode_packed_decimal_to_string_with_scratch(
4171 data: &[u8],
4172 digits: u16,
4173 scale: i16,
4174 signed: bool,
4175 scratch: &mut ScratchBuffers,
4176) -> Result<String> {
4177 // SIMD-friendly sign lookup table for faster branch-free sign detection
4178 // Index by nibble value: 0=invalid, 1=positive, 2=negative
4179 const SIGN_TABLE: [u8; 16] = [
4180 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x0-0x9: invalid
4181 1, 2, 1, 2, 1, 1, // 0xA=pos, 0xB=neg, 0xC=pos, 0xD=neg, 0xE=pos, 0xF=pos
4182 ];
4183
4184 // CRITICAL OPTIMIZATION: Direct decode-to-string path to avoid SmallDecimal allocation
4185 if data.is_empty() {
4186 return Ok("0".to_string());
4187 }
4188
4189 // Fast path for common single-digit packed decimals
4190 if data.len() == 1 && digits == 1 {
4191 let byte = data[0];
4192 let high_nibble = (byte >> 4) & 0x0F;
4193 let low_nibble = byte & 0x0F;
4194
4195 let mut is_negative = false;
4196
4197 // Single digit: high nibble is unused (should be 0), low nibble is sign
4198 if high_nibble > 9 {
4199 return Err(Error::new(
4200 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4201 format!("Invalid digit nibble 0x{high_nibble:X}"),
4202 ));
4203 }
4204 let value = i64::from(high_nibble);
4205
4206 if signed {
4207 // SIMD-friendly branch-free sign detection
4208 let sign_code = SIGN_TABLE[usize::from(low_nibble)];
4209 if sign_code == 0 {
4210 return Err(Error::new(
4211 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4212 format!("Invalid sign nibble 0x{low_nibble:X}"),
4213 ));
4214 }
4215 is_negative = sign_code == 2;
4216 } else if low_nibble != 0xF {
4217 return Err(Error::new(
4218 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4219 format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
4220 ));
4221 }
4222
4223 // Format directly to string without SmallDecimal
4224 scratch.string_buffer.clear();
4225 if is_negative && value != 0 {
4226 scratch.string_buffer.push('-');
4227 }
4228
4229 if scale <= 0 {
4230 // Integer format
4231 let scaled_value = if scale < 0 {
4232 value * 10_i64.pow(scale_abs_to_u32(scale))
4233 } else {
4234 value
4235 };
4236 format_integer_to_buffer(scaled_value, &mut scratch.string_buffer);
4237 } else {
4238 // Decimal format
4239 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
4240 let integer_part = value / divisor;
4241 let fractional_part = value % divisor;
4242
4243 format_integer_to_buffer(integer_part, &mut scratch.string_buffer);
4244 scratch.string_buffer.push('.');
4245 format_integer_with_leading_zeros_to_buffer(
4246 fractional_part,
4247 scale_abs_to_u32(scale),
4248 &mut scratch.string_buffer,
4249 );
4250 }
4251
4252 // CRITICAL OPTIMIZATION: Move string content without cloning
4253 let result = std::mem::take(&mut scratch.string_buffer);
4254 return Ok(result);
4255 }
4256
4257 // Fall back to general case for larger packed decimals
4258 let decimal = decode_packed_decimal_with_scratch(data, digits, scale, signed, scratch)?;
4259
4260 // Now format to string using the optimized scratch buffer method
4261 decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
4262
4263 // CRITICAL OPTIMIZATION: Move string content without cloning
4264 let result = std::mem::take(&mut scratch.string_buffer);
4265 Ok(result)
4266}
4267
4268/// Format a binary integer into the caller-owned scratch buffer.
4269///
4270/// ## Why scratch?
4271/// Avoids hot-path allocations in codec routes that emit integers frequently
4272/// (zoned/packed/binary). This writes into `scratch` and returns that buffer,
4273/// so callers must reuse the same `ScratchBuffers` instance across a walk.
4274///
4275/// ## Contract
4276/// - No allocations on the hot path
4277/// - Returns the scratch-backed `String` (valid until next reuse/clear)
4278#[inline]
4279#[must_use = "Use the formatted string or continue mutating the scratch buffer"]
4280pub fn format_binary_int_to_string_with_scratch(
4281 value: i64,
4282 scratch: &mut ScratchBuffers,
4283) -> String {
4284 scratch.string_buffer.clear();
4285
4286 if value < 0 {
4287 scratch.string_buffer.push('-');
4288 if value == i64::MIN {
4289 // Avoid overflow when negating i64::MIN
4290 scratch.string_buffer.push_str("9223372036854775808");
4291 return std::mem::take(&mut scratch.string_buffer);
4292 }
4293 format_integer_to_buffer(-value, &mut scratch.string_buffer);
4294 } else {
4295 format_integer_to_buffer(value, &mut scratch.string_buffer);
4296 }
4297
4298 std::mem::take(&mut scratch.string_buffer)
4299}
4300
4301/// Format an integer to a string buffer with optimized performance
4302///
4303/// Provides ultra-fast integer-to-string conversion optimized for COBOL numeric
4304/// decoding hot paths. Uses manual digit extraction to avoid format macro overhead.
4305///
4306/// # Arguments
4307/// * `value` - Integer value to format
4308/// * `buffer` - String buffer to append digits to
4309///
4310/// # Performance
4311/// Critical optimization for COMP-3 and zoned decimal JSON conversion. Avoids
4312/// the overhead of Rust's standard formatting macros through manual digit extraction.
4313///
4314/// # Examples
4315/// ```text
4316/// let mut buffer = String::new();
4317/// format_integer_to_buffer(12345, &mut buffer);
4318/// assert_eq!(buffer, "12345");
4319/// ```
4320#[inline]
4321fn format_integer_to_buffer(value: i64, buffer: &mut String) {
4322 SmallDecimal::format_integer_manual(value, buffer);
4323}
4324
4325/// Format an integer with leading zeros to a string buffer
4326///
4327/// Formats an integer with exactly `width` digits, padding with leading zeros
4328/// if necessary. Optimized for decimal formatting where fractional parts must
4329/// maintain precise digit counts.
4330///
4331/// # Arguments
4332/// * `value` - Integer value to format
4333/// * `width` - Number of digits in output (with leading zeros)
4334/// * `buffer` - String buffer to append formatted digits to
4335///
4336/// # Performance
4337/// Optimized for common COBOL scales (0-4 decimal places) with specialized
4338/// fast paths. Critical for maintaining COMP-3 decimal precision.
4339///
4340/// # Examples
4341/// ```text
4342/// let mut buffer = String::new();
4343/// format_integer_with_leading_zeros_to_buffer(45, 4, &mut buffer);
4344/// assert_eq!(buffer, "0045");
4345/// ```
4346#[inline]
4347fn format_integer_with_leading_zeros_to_buffer(value: i64, width: u32, buffer: &mut String) {
4348 SmallDecimal::format_integer_with_leading_zeros(value, width, buffer);
4349}
4350
4351/// Decode a zoned decimal directly to a `String`, bypassing the intermediate
4352/// [`SmallDecimal`] allocation.
4353///
4354/// Analogous to [`decode_packed_decimal_to_string_with_scratch`] but for zoned
4355/// decimal (PIC 9 / PIC S9) fields. Decodes via
4356/// [`decode_zoned_decimal_with_scratch`] and then formats the result into the
4357/// scratch string buffer, avoiding a separate heap allocation.
4358///
4359/// # Arguments
4360/// * `data` - Raw byte data containing the zoned decimal
4361/// * `digits` - Number of digit characters (field length)
4362/// * `scale` - Number of implied decimal places (can be negative for scaling)
4363/// * `signed` - Whether the field carries a sign (overpunch in last byte)
4364/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
4365/// * `blank_when_zero` - If `true`, all-space fields decode as `"0"`
4366/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
4367/// `std::mem::take` and returned as the result string.
4368///
4369/// # Returns
4370/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
4371///
4372/// # Policy
4373/// Mirrors [`decode_zoned_decimal_with_scratch`], inheriting its default
4374/// preferred-zero handling for EBCDIC data.
4375///
4376/// # Errors
4377/// * `CBKD411_ZONED_BAD_SIGN` - if the zone nibbles or sign are invalid
4378///
4379/// # See Also
4380/// * [`decode_zoned_decimal`] - Returns a `SmallDecimal` instead of a string
4381/// * [`decode_zoned_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
4382/// * [`decode_packed_decimal_to_string_with_scratch`] - Equivalent for packed decimals
4383#[inline]
4384#[must_use = "Handle the Result or propagate the error"]
4385pub fn decode_zoned_decimal_to_string_with_scratch(
4386 data: &[u8],
4387 digits: u16,
4388 scale: i16,
4389 signed: bool,
4390 codepage: Codepage,
4391 blank_when_zero: bool,
4392 scratch: &mut ScratchBuffers,
4393) -> Result<String> {
4394 // First decode to SmallDecimal using existing optimized decoder
4395 let decimal = decode_zoned_decimal_with_scratch(
4396 data,
4397 digits,
4398 scale,
4399 signed,
4400 codepage,
4401 blank_when_zero,
4402 scratch,
4403 )?;
4404
4405 // Special-case integer zoned decimals for digit padding consistency
4406 if scale == 0 && !blank_when_zero {
4407 if decimal.value == 0 {
4408 scratch.string_buffer.clear();
4409 scratch.string_buffer.push('0');
4410 } else {
4411 scratch.string_buffer.clear();
4412 if decimal.negative && decimal.value != 0 {
4413 scratch.string_buffer.push('-');
4414 }
4415
4416 let magnitude = if decimal.scale < 0 {
4417 decimal.value * 10_i64.pow(scale_abs_to_u32(decimal.scale))
4418 } else {
4419 decimal.value
4420 };
4421
4422 SmallDecimal::format_integer_with_leading_zeros(
4423 magnitude,
4424 u32::from(digits),
4425 &mut scratch.string_buffer,
4426 );
4427 }
4428
4429 return Ok(std::mem::take(&mut scratch.string_buffer));
4430 }
4431
4432 // Fallback to general fixed-scale formatting using scratch buffer
4433 decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
4434 Ok(std::mem::take(&mut scratch.string_buffer))
4435}
4436
4437// =============================================================================
4438// Floating-Point Codecs (COMP-1 / COMP-2)
4439// =============================================================================
4440
4441const IBM_HEX_EXPONENT_BIAS: i32 = 64;
4442const IBM_HEX_FRACTION_MIN: f64 = 1.0 / 16.0;
4443const IBM_HEX_SINGLE_FRACTION_BITS: u32 = 24;
4444const IBM_HEX_DOUBLE_FRACTION_BITS: u32 = 56;
4445
4446#[inline]
4447fn validate_float_buffer_len(data: &[u8], required: usize, usage: &str) -> Result<()> {
4448 if data.len() < required {
4449 return Err(Error::new(
4450 ErrorCode::CBKD301_RECORD_TOO_SHORT,
4451 format!("{usage} requires {required} bytes, got {}", data.len()),
4452 ));
4453 }
4454 Ok(())
4455}
4456
4457#[inline]
4458fn validate_float_encode_buffer_len(buffer: &[u8], required: usize, usage: &str) -> Result<()> {
4459 if buffer.len() < required {
4460 return Err(Error::new(
4461 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4462 format!(
4463 "{usage} requires {required} bytes, buffer has {}",
4464 buffer.len()
4465 ),
4466 ));
4467 }
4468 Ok(())
4469}
4470
4471#[inline]
4472#[allow(clippy::cast_precision_loss)]
4473fn decode_ibm_hex_to_f64(sign: bool, exponent_raw: u8, fraction_bits: u64, bits: u32) -> f64 {
4474 if exponent_raw == 0 && fraction_bits == 0 {
4475 return if sign { -0.0 } else { 0.0 };
4476 }
4477
4478 let exponent = i32::from(exponent_raw) - IBM_HEX_EXPONENT_BIAS;
4479 let divisor = 2_f64.powi(i32::try_from(bits).unwrap_or(0));
4480 let fraction = (fraction_bits as f64) / divisor;
4481 let magnitude = fraction * 16_f64.powi(exponent);
4482 if sign { -magnitude } else { magnitude }
4483}
4484
4485#[inline]
4486#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4487fn encode_f64_to_ibm_hex_parts(value: f64, bits: u32) -> Result<(u8, u64)> {
4488 if !value.is_finite() {
4489 return Err(Error::new(
4490 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4491 "IBM hex float encoding requires finite values",
4492 ));
4493 }
4494
4495 if value == 0.0 {
4496 return Ok((0, 0));
4497 }
4498
4499 let mut exponent = IBM_HEX_EXPONENT_BIAS;
4500 let mut fraction = value.abs();
4501
4502 while fraction < IBM_HEX_FRACTION_MIN {
4503 fraction *= 16.0;
4504 exponent -= 1;
4505 if exponent <= 0 {
4506 return Ok((0, 0));
4507 }
4508 }
4509
4510 while fraction >= 1.0 {
4511 fraction /= 16.0;
4512 exponent += 1;
4513 if exponent >= 128 {
4514 return Err(Error::new(
4515 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4516 "IBM hex float exponent overflow",
4517 ));
4518 }
4519 }
4520
4521 let scale = 2_f64.powi(i32::try_from(bits).unwrap_or(0));
4522 let mut fraction_bits = (fraction * scale).round() as u64;
4523 let full_scale = 1_u64 << bits;
4524 if fraction_bits >= full_scale {
4525 // Carry from rounding: re-normalize to 0x1... and bump exponent.
4526 fraction_bits = 1_u64 << (bits - 4);
4527 exponent += 1;
4528 if exponent >= 128 {
4529 return Err(Error::new(
4530 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4531 "IBM hex float exponent overflow",
4532 ));
4533 }
4534 }
4535
4536 Ok((u8::try_from(exponent).unwrap_or(0), fraction_bits))
4537}
4538
4539/// Decode a COMP-1 field in IEEE-754 big-endian format.
4540///
4541/// # Errors
4542/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 4 bytes.
4543#[inline]
4544pub fn decode_float_single_ieee_be(data: &[u8]) -> Result<f32> {
4545 validate_float_buffer_len(data, 4, "COMP-1")?;
4546 let bytes: [u8; 4] = [data[0], data[1], data[2], data[3]];
4547 Ok(f32::from_be_bytes(bytes))
4548}
4549
4550/// Decode a COMP-2 field in IEEE-754 big-endian format.
4551///
4552/// # Errors
4553/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 8 bytes.
4554#[inline]
4555pub fn decode_float_double_ieee_be(data: &[u8]) -> Result<f64> {
4556 validate_float_buffer_len(data, 8, "COMP-2")?;
4557 let bytes: [u8; 8] = [
4558 data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
4559 ];
4560 Ok(f64::from_be_bytes(bytes))
4561}
4562
4563/// Decode a COMP-1 field in IBM hexadecimal floating-point format.
4564///
4565/// # Errors
4566/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 4 bytes.
4567#[inline]
4568pub fn decode_float_single_ibm_hex(data: &[u8]) -> Result<f32> {
4569 validate_float_buffer_len(data, 4, "COMP-1")?;
4570 let word = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
4571 let sign = (word & 0x8000_0000) != 0;
4572 let exponent_raw = ((word >> 24) & 0x7F) as u8;
4573 let fraction_bits = u64::from(word & 0x00FF_FFFF);
4574 let value = decode_ibm_hex_to_f64(
4575 sign,
4576 exponent_raw,
4577 fraction_bits,
4578 IBM_HEX_SINGLE_FRACTION_BITS,
4579 );
4580 #[allow(clippy::cast_possible_truncation)]
4581 {
4582 Ok(value as f32)
4583 }
4584}
4585
4586/// Decode a COMP-2 field in IBM hexadecimal floating-point format.
4587///
4588/// # Errors
4589/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 8 bytes.
4590#[inline]
4591pub fn decode_float_double_ibm_hex(data: &[u8]) -> Result<f64> {
4592 validate_float_buffer_len(data, 8, "COMP-2")?;
4593 let word = u64::from_be_bytes([
4594 data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
4595 ]);
4596 let sign = (word & 0x8000_0000_0000_0000) != 0;
4597 let exponent_raw = ((word >> 56) & 0x7F) as u8;
4598 let fraction_bits = word & 0x00FF_FFFF_FFFF_FFFF;
4599 Ok(decode_ibm_hex_to_f64(
4600 sign,
4601 exponent_raw,
4602 fraction_bits,
4603 IBM_HEX_DOUBLE_FRACTION_BITS,
4604 ))
4605}
4606
4607/// Decode a COMP-1 float with explicit format selection.
4608///
4609/// # Errors
4610/// Returns format-specific decode errors.
4611#[inline]
4612pub fn decode_float_single_with_format(data: &[u8], format: FloatFormat) -> Result<f32> {
4613 match format {
4614 FloatFormat::IeeeBigEndian => decode_float_single_ieee_be(data),
4615 FloatFormat::IbmHex => decode_float_single_ibm_hex(data),
4616 }
4617}
4618
4619/// Decode a COMP-2 float with explicit format selection.
4620///
4621/// # Errors
4622/// Returns format-specific decode errors.
4623#[inline]
4624pub fn decode_float_double_with_format(data: &[u8], format: FloatFormat) -> Result<f64> {
4625 match format {
4626 FloatFormat::IeeeBigEndian => decode_float_double_ieee_be(data),
4627 FloatFormat::IbmHex => decode_float_double_ibm_hex(data),
4628 }
4629}
4630
4631/// Decode a COMP-1 float with default IEEE-754 big-endian interpretation.
4632///
4633/// # Errors
4634/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 4 bytes.
4635#[inline]
4636pub fn decode_float_single(data: &[u8]) -> Result<f32> {
4637 decode_float_single_ieee_be(data)
4638}
4639
4640/// Decode a COMP-2 float with default IEEE-754 big-endian interpretation.
4641///
4642/// # Errors
4643/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 8 bytes.
4644#[inline]
4645pub fn decode_float_double(data: &[u8]) -> Result<f64> {
4646 decode_float_double_ieee_be(data)
4647}
4648
4649/// Encode a COMP-1 value in IEEE-754 big-endian format.
4650///
4651/// # Errors
4652/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 4 bytes.
4653#[inline]
4654pub fn encode_float_single_ieee_be(value: f32, buffer: &mut [u8]) -> Result<()> {
4655 validate_float_encode_buffer_len(buffer, 4, "COMP-1")?;
4656 let bytes = value.to_be_bytes();
4657 buffer[..4].copy_from_slice(&bytes);
4658 Ok(())
4659}
4660
4661/// Encode a COMP-2 value in IEEE-754 big-endian format.
4662///
4663/// # Errors
4664/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 8 bytes.
4665#[inline]
4666pub fn encode_float_double_ieee_be(value: f64, buffer: &mut [u8]) -> Result<()> {
4667 validate_float_encode_buffer_len(buffer, 8, "COMP-2")?;
4668 let bytes = value.to_be_bytes();
4669 buffer[..8].copy_from_slice(&bytes);
4670 Ok(())
4671}
4672
4673/// Encode a COMP-1 value in IBM hexadecimal floating-point format.
4674///
4675/// # Errors
4676/// Returns:
4677/// - `CBKE510_NUMERIC_OVERFLOW` if the buffer is too small
4678/// - `CBKE510_NUMERIC_OVERFLOW` for non-finite values or exponent overflow
4679#[inline]
4680pub fn encode_float_single_ibm_hex(value: f32, buffer: &mut [u8]) -> Result<()> {
4681 validate_float_encode_buffer_len(buffer, 4, "COMP-1")?;
4682 let sign = value.is_sign_negative();
4683 let (exponent_raw, fraction_bits) =
4684 encode_f64_to_ibm_hex_parts(f64::from(value), IBM_HEX_SINGLE_FRACTION_BITS)?;
4685 let sign_bit = if sign { 0x8000_0000 } else { 0 };
4686 let fraction_low = u32::try_from(fraction_bits & 0x00FF_FFFF).map_err(|_| {
4687 Error::new(
4688 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4689 "IBM hex fraction overflow for COMP-1",
4690 )
4691 })?;
4692 let word = sign_bit | (u32::from(exponent_raw) << 24) | fraction_low;
4693 buffer[..4].copy_from_slice(&word.to_be_bytes());
4694 Ok(())
4695}
4696
4697/// Encode a COMP-2 value in IBM hexadecimal floating-point format.
4698///
4699/// # Errors
4700/// Returns:
4701/// - `CBKE510_NUMERIC_OVERFLOW` if the buffer is too small
4702/// - `CBKE510_NUMERIC_OVERFLOW` for non-finite values or exponent overflow
4703#[inline]
4704pub fn encode_float_double_ibm_hex(value: f64, buffer: &mut [u8]) -> Result<()> {
4705 validate_float_encode_buffer_len(buffer, 8, "COMP-2")?;
4706 let sign = value.is_sign_negative();
4707 let (exponent_raw, fraction_bits) =
4708 encode_f64_to_ibm_hex_parts(value, IBM_HEX_DOUBLE_FRACTION_BITS)?;
4709 let sign_bit = if sign { 0x8000_0000_0000_0000 } else { 0 };
4710 let word = sign_bit | (u64::from(exponent_raw) << 56) | (fraction_bits & 0x00FF_FFFF_FFFF_FFFF);
4711 buffer[..8].copy_from_slice(&word.to_be_bytes());
4712 Ok(())
4713}
4714
4715/// Encode a COMP-1 float with explicit format selection.
4716///
4717/// # Errors
4718/// Returns format-specific encode errors.
4719#[inline]
4720pub fn encode_float_single_with_format(
4721 value: f32,
4722 buffer: &mut [u8],
4723 format: FloatFormat,
4724) -> Result<()> {
4725 match format {
4726 FloatFormat::IeeeBigEndian => encode_float_single_ieee_be(value, buffer),
4727 FloatFormat::IbmHex => encode_float_single_ibm_hex(value, buffer),
4728 }
4729}
4730
4731/// Encode a COMP-2 float with explicit format selection.
4732///
4733/// # Errors
4734/// Returns format-specific encode errors.
4735#[inline]
4736pub fn encode_float_double_with_format(
4737 value: f64,
4738 buffer: &mut [u8],
4739 format: FloatFormat,
4740) -> Result<()> {
4741 match format {
4742 FloatFormat::IeeeBigEndian => encode_float_double_ieee_be(value, buffer),
4743 FloatFormat::IbmHex => encode_float_double_ibm_hex(value, buffer),
4744 }
4745}
4746
4747/// Encode a COMP-1 float with default IEEE-754 big-endian format.
4748///
4749/// # Errors
4750/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 4 bytes.
4751#[inline]
4752pub fn encode_float_single(value: f32, buffer: &mut [u8]) -> Result<()> {
4753 encode_float_single_ieee_be(value, buffer)
4754}
4755
4756/// Encode a COMP-2 float with default IEEE-754 big-endian format.
4757///
4758/// # Errors
4759/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 8 bytes.
4760#[inline]
4761pub fn encode_float_double(value: f64, buffer: &mut [u8]) -> Result<()> {
4762 encode_float_double_ieee_be(value, buffer)
4763}
4764
4765#[cfg(test)]
4766#[allow(clippy::expect_used)]
4767#[allow(clippy::unwrap_used)]
4768#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
4769mod tests {
4770 use super::*;
4771 use crate::zoned_overpunch::{ZeroSignPolicy, encode_overpunch_byte, is_valid_overpunch};
4772 use proptest::prelude::*;
4773 use proptest::test_runner::RngSeed;
4774 use std::collections::hash_map::DefaultHasher;
4775 use std::hash::{Hash, Hasher};
4776
4777 fn proptest_case_count() -> u32 {
4778 option_env!("PROPTEST_CASES")
4779 .and_then(|s| s.parse().ok())
4780 .unwrap_or(256)
4781 }
4782
4783 fn numeric_proptest_config() -> ProptestConfig {
4784 let mut cfg = ProptestConfig {
4785 cases: proptest_case_count(),
4786 max_shrink_time: 0,
4787 ..ProptestConfig::default()
4788 };
4789
4790 if let Ok(seed_value) = std::env::var("PROPTEST_SEED")
4791 && !seed_value.is_empty()
4792 {
4793 let parsed_seed = seed_value.parse::<u64>().unwrap_or_else(|_| {
4794 let mut hasher = DefaultHasher::new();
4795 seed_value.hash(&mut hasher);
4796 hasher.finish()
4797 });
4798 cfg.rng_seed = RngSeed::Fixed(parsed_seed);
4799 }
4800
4801 cfg
4802 }
4803
4804 #[test]
4805 fn test_small_decimal_normalization() {
4806 let mut decimal = SmallDecimal::new(0, 2, true);
4807 decimal.normalize();
4808 assert!(!decimal.negative); // -0 should become 0
4809 }
4810
4811 #[test]
4812 fn test_small_decimal_formatting() {
4813 // Integer format (scale=0)
4814 let decimal = SmallDecimal::new(123, 0, false);
4815 assert_eq!(decimal.to_string(), "123");
4816
4817 // Decimal format with fixed scale
4818 let decimal = SmallDecimal::new(12345, 2, false);
4819 assert_eq!(decimal.to_string(), "123.45");
4820
4821 // Negative decimal
4822 let decimal = SmallDecimal::new(12345, 2, true);
4823 assert_eq!(decimal.to_string(), "-123.45");
4824 }
4825
4826 #[test]
4827 fn test_zero_with_scale_preserves_decimal_places() {
4828 // Zero with scale=2 must produce "0.00" (not "0")
4829 let decimal = SmallDecimal::new(0, 2, false);
4830 assert_eq!(decimal.to_string(), "0.00");
4831
4832 // Zero with scale=1
4833 let decimal = SmallDecimal::new(0, 1, false);
4834 assert_eq!(decimal.to_string(), "0.0");
4835
4836 // Zero with scale=4 and negative flag (normalizes sign away)
4837 let decimal = SmallDecimal::new(0, 4, true);
4838 assert_eq!(decimal.to_string(), "0.0000");
4839 }
4840
4841 proptest! {
4842 #![proptest_config(numeric_proptest_config())]
4843 #[test]
4844 fn prop_zoned_digit_buffer_contains_only_digits(
4845 digits_vec in prop::collection::vec(0u8..=9, 1..=12),
4846 signed in any::<bool>(),
4847 allow_negative in any::<bool>(),
4848 codepage in prop_oneof![
4849 Just(Codepage::ASCII),
4850 Just(Codepage::CP037),
4851 Just(Codepage::CP273),
4852 Just(Codepage::CP500),
4853 Just(Codepage::CP1047),
4854 Just(Codepage::CP1140),
4855 ],
4856 policy in prop_oneof![Just(ZeroSignPolicy::Positive), Just(ZeroSignPolicy::Preferred)],
4857 ) {
4858 let digit_count = u16::try_from(digits_vec.len()).expect("vector length <= 12");
4859 let mut bytes = Vec::with_capacity(digits_vec.len());
4860
4861 for digit in digits_vec.iter().take(digits_vec.len().saturating_sub(1)) {
4862 let byte = if codepage.is_ascii() {
4863 0x30 + digit
4864 } else {
4865 0xF0 + digit
4866 };
4867 bytes.push(byte);
4868 }
4869
4870 let is_negative = signed && allow_negative;
4871 let last_digit = *digits_vec.last().expect("vector is non-empty");
4872 let last_byte = if signed {
4873 let encoded = encode_overpunch_byte(last_digit, is_negative, codepage, policy)
4874 .expect("valid overpunch for digit 0-9");
4875 prop_assume!(is_valid_overpunch(encoded, codepage));
4876 encoded
4877 } else if codepage.is_ascii() {
4878 0x30 + last_digit
4879 } else {
4880 0xF0 + last_digit
4881 };
4882 bytes.push(last_byte);
4883
4884 let mut scratch = ScratchBuffers::new();
4885 let _ = decode_zoned_decimal_with_scratch(
4886 &bytes,
4887 digit_count,
4888 0,
4889 signed,
4890 codepage,
4891 false,
4892 &mut scratch,
4893 ).expect("decoding constructed zoned bytes should succeed");
4894
4895 prop_assert_eq!(scratch.digit_buffer.len(), digits_vec.len());
4896 prop_assert!(scratch.digit_buffer.iter().all(|&d| d <= 9));
4897 prop_assert_eq!(&scratch.digit_buffer[..], &digits_vec[..]);
4898 }
4899 }
4900
4901 #[test]
4902 fn test_zoned_decimal_blank_when_zero() {
4903 // EBCDIC spaces (0x40)
4904 let data = vec![0x40, 0x40, 0x40];
4905 let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, true).unwrap();
4906 assert_eq!(result.to_string(), "0");
4907
4908 // ASCII spaces
4909 let data = vec![b' ', b' ', b' '];
4910 let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::ASCII, true).unwrap();
4911 assert_eq!(result.to_string(), "0");
4912 }
4913
4914 #[test]
4915 fn test_packed_decimal_signs() {
4916 // Positive packed decimal: 123C (123 positive)
4917 let data = vec![0x12, 0x3C];
4918 let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
4919 assert_eq!(result.to_string(), "123");
4920
4921 // Negative packed decimal: 123D (123 negative)
4922 let data = vec![0x12, 0x3D];
4923 let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
4924 assert_eq!(result.to_string(), "-123");
4925
4926 // Test the failing case from property tests: -11 (2 digits)
4927 // Test that round-trip encoding/decoding preserves the sign
4928 let encoded = encode_packed_decimal("-11", 2, 0, true).unwrap();
4929 let result = decode_packed_decimal(&encoded, 2, 0, true).unwrap();
4930 assert_eq!(
4931 result.to_string(),
4932 "-11",
4933 "Failed to round-trip -11 correctly"
4934 );
4935
4936 // Test that the old buggy format is now rejected
4937 let data = vec![0x11, 0xDD]; // Invalid format with sign in both nibbles
4938 let result = decode_packed_decimal(&data, 2, 0, true);
4939 assert!(
4940 result.is_err(),
4941 "Should reject invalid format with sign in both nibbles"
4942 );
4943 }
4944
4945 #[test]
4946 fn test_binary_int_big_endian() {
4947 // 16-bit big-endian: 0x0123 = 291
4948 let data = vec![0x01, 0x23];
4949 let result = decode_binary_int(&data, 16, false).unwrap();
4950 assert_eq!(result, 291);
4951
4952 // 32-bit big-endian: 0x01234567 = 19088743
4953 let data = vec![0x01, 0x23, 0x45, 0x67];
4954 let result = decode_binary_int(&data, 32, false).unwrap();
4955 assert_eq!(result, 19_088_743);
4956 }
4957
4958 #[test]
4959 fn test_alphanumeric_encoding() {
4960 // ASCII encoding with padding
4961 let result = encode_alphanumeric("HELLO", 10, Codepage::ASCII).unwrap();
4962 assert_eq!(result, b"HELLO ");
4963
4964 // Over-length should error
4965 let result = encode_alphanumeric("HELLO WORLD", 5, Codepage::ASCII);
4966 assert!(result.is_err());
4967 }
4968
4969 #[test]
4970 fn test_bwz_policy() {
4971 // Zero values should trigger BWZ
4972 assert!(should_encode_as_blank_when_zero("0", true));
4973 assert!(should_encode_as_blank_when_zero("0.00", true));
4974 assert!(should_encode_as_blank_when_zero("0.000", true));
4975
4976 // Non-zero values should not trigger BWZ
4977 assert!(!should_encode_as_blank_when_zero("1", true));
4978 assert!(!should_encode_as_blank_when_zero("0.01", true));
4979
4980 // BWZ disabled should never trigger
4981 assert!(!should_encode_as_blank_when_zero("0", false));
4982 }
4983
4984 #[test]
4985 fn test_binary_width_mapping() {
4986 // Test digit-to-width mapping (NORMATIVE)
4987 assert_eq!(get_binary_width_from_digits(1), 16); // ≤4 → 2B
4988 assert_eq!(get_binary_width_from_digits(4), 16); // ≤4 → 2B
4989 assert_eq!(get_binary_width_from_digits(5), 32); // 5-9 → 4B
4990 assert_eq!(get_binary_width_from_digits(9), 32); // 5-9 → 4B
4991 assert_eq!(get_binary_width_from_digits(10), 64); // 10-18 → 8B
4992 assert_eq!(get_binary_width_from_digits(18), 64); // 10-18 → 8B
4993 }
4994
4995 #[test]
4996 fn test_explicit_binary_width_validation() {
4997 // Valid explicit widths
4998 assert_eq!(validate_explicit_binary_width(1).unwrap(), 8);
4999 assert_eq!(validate_explicit_binary_width(2).unwrap(), 16);
5000 assert_eq!(validate_explicit_binary_width(4).unwrap(), 32);
5001 assert_eq!(validate_explicit_binary_width(8).unwrap(), 64);
5002
5003 // Invalid explicit widths
5004 assert!(validate_explicit_binary_width(3).is_err());
5005 assert!(validate_explicit_binary_width(16).is_err());
5006 }
5007
5008 #[test]
5009 fn test_zoned_decimal_with_bwz() {
5010 // BWZ enabled with zero value should return spaces
5011 let result =
5012 encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, true).unwrap();
5013 assert_eq!(result, vec![b' ', b' ', b' ']);
5014
5015 // BWZ disabled with zero value should return normal encoding
5016 let result =
5017 encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, false).unwrap();
5018 assert_eq!(result, vec![0x30, 0x30, 0x30]); // ASCII "000"
5019
5020 // Non-zero value should return normal encoding regardless of BWZ
5021 let result =
5022 encode_zoned_decimal_with_bwz("123", 3, 0, false, Codepage::ASCII, true).unwrap();
5023 assert_eq!(result, vec![0x31, 0x32, 0x33]); // ASCII "123"
5024 }
5025
5026 #[test]
5027 fn test_error_handling_invalid_numeric_inputs() {
5028 // Test packed decimal with invalid input - should return specific CBKD error
5029 let invalid_data = vec![0xFF]; // Invalid packed decimal
5030 let result = decode_packed_decimal(&invalid_data, 2, 0, false);
5031 assert!(
5032 result.is_err(),
5033 "Invalid packed decimal should return error"
5034 );
5035
5036 let error = result.unwrap_err();
5037 assert!(
5038 error.to_string().contains("CBKD"),
5039 "Error should be CBKD code"
5040 );
5041
5042 // Test binary int with insufficient data
5043 let short_data = vec![0x01]; // Only 1 byte for 4-byte int
5044 let result = decode_binary_int(&short_data, 32, false);
5045 assert!(
5046 result.is_err(),
5047 "Insufficient binary data should return error"
5048 );
5049
5050 // Test zoned decimal with invalid characters
5051 let invalid_zoned = b"12X"; // Contains non-digit
5052 let result = decode_zoned_decimal(invalid_zoned, 3, 0, false, Codepage::ASCII, false);
5053 assert!(result.is_err(), "Invalid zoned decimal should return error");
5054
5055 // Test alphanumeric encoding with oversized input
5056 let result = encode_alphanumeric("TOOLONGFORFIELD", 5, Codepage::ASCII);
5057 assert!(
5058 result.is_err(),
5059 "Oversized alphanumeric should return error"
5060 );
5061
5062 let error = result.unwrap_err();
5063 assert!(
5064 error.to_string().contains("CBKE"),
5065 "Error should be CBKE code"
5066 );
5067 }
5068
5069 #[test]
5070 fn test_boundary_conditions_numeric_operations() {
5071 // Test maximum values for different data types
5072
5073 // Test maximum packed decimal
5074 let max_packed_bytes = vec![0x99, 0x9C]; // 999 positive (3 digits)
5075 let result = decode_packed_decimal(&max_packed_bytes, 3, 0, true);
5076 assert!(
5077 result.is_ok(),
5078 "Valid maximum packed decimal should succeed"
5079 );
5080
5081 // Test zero packed decimal
5082 let zero_packed = vec![0x00, 0x0C]; // 00 positive
5083 let result = decode_packed_decimal(&zero_packed, 2, 0, true);
5084 assert!(result.is_ok(), "Zero packed decimal should succeed");
5085
5086 // Test edge case with maximum binary values
5087 let max_u16_bytes = vec![0xFF, 0xFF];
5088 let result = decode_binary_int(&max_u16_bytes, 16, false);
5089 assert!(result.is_ok(), "Maximum unsigned 16-bit should succeed");
5090
5091 let max_signed_16_bytes = vec![0x7F, 0xFF];
5092 let result = decode_binary_int(&max_signed_16_bytes, 16, true);
5093 assert!(result.is_ok(), "Maximum signed 16-bit should succeed");
5094
5095 // Test edge case with minimum signed values
5096 let min_i16_bytes = vec![0x80, 0x00];
5097 let result = decode_binary_int(&min_i16_bytes, 16, true);
5098 assert!(result.is_ok(), "Minimum signed 16-bit should succeed");
5099 }
5100
5101 #[test]
5102 fn test_comp3_decimal_scale_fix() {
5103 // Test case for PIC S9(7)V99 COMP-3 with decimal positioning fix
5104 let input_value = "123.45";
5105 let digits = 9; // 7 integer + 2 decimal = 9 total digits
5106 let scale = 2; // 2 decimal places
5107 let signed = true;
5108
5109 // Test round-trip encoding/decoding
5110 let encoded_data = encode_packed_decimal(input_value, digits, scale, signed).unwrap();
5111 let decoded = decode_packed_decimal(&encoded_data, digits, scale, signed).unwrap();
5112
5113 assert_eq!(decoded.to_string(), "123.45", "COMP-3 round-trip failed");
5114
5115 // Test negative case
5116 let negative_value = "-999.99";
5117 let encoded_neg = encode_packed_decimal(negative_value, digits, scale, signed).unwrap();
5118 let decoded_neg = decode_packed_decimal(&encoded_neg, digits, scale, signed).unwrap();
5119
5120 assert_eq!(
5121 decoded_neg.to_string(),
5122 "-999.99",
5123 "Negative COMP-3 round-trip failed"
5124 );
5125 }
5126
5127 #[test]
5128 fn test_error_path_coverage_arithmetic_operations() {
5129 // Test SmallDecimal creation and basic operations
5130 let decimal = SmallDecimal::new(i64::MAX, 0, false);
5131 assert_eq!(decimal.value, i64::MAX);
5132 assert_eq!(decimal.scale, 0);
5133 assert!(!decimal.negative);
5134
5135 // Test boundary conditions for large values
5136 let large_decimal = SmallDecimal::new(999_999_999, 0, false);
5137 assert_eq!(large_decimal.value, 999_999_999);
5138
5139 // Test boundary conditions for scale normalization
5140 let mut small_decimal = SmallDecimal::new(1, 10, false);
5141 small_decimal.normalize(); // Should handle high scale
5142 assert!(small_decimal.scale >= 0);
5143
5144 // Test signed/unsigned conversions with boundary values
5145 let negative_decimal = SmallDecimal::new(-1, 0, true);
5146 assert!(
5147 negative_decimal.is_negative(),
5148 "Signed negative should be negative"
5149 );
5150
5151 let positive_decimal = SmallDecimal::new(1, 0, false);
5152 assert!(
5153 !positive_decimal.is_negative(),
5154 "Unsigned should not be negative"
5155 );
5156 }
5157}