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//! ### Internal Single-Responsibility Modules
42//! - `branch` owns internal branch-prediction hints for numeric hot paths.
43//! - `binary` owns COMP/BINARY integer codecs and binary width helpers.
44//! - `decimal` owns [`SmallDecimal`](crate::numeric::SmallDecimal), decimal formatting helpers,
45//! and zoned encoding detection metadata.
46//! - `float` owns COMP-1/COMP-2 IEEE and IBM hexadecimal floating-point codecs.
47//!
48//! ## Performance Considerations
49//!
50//! This module is optimized for high-throughput enterprise data processing:
51//!
52//! - **Hot path optimization**: Common cases (1-5 byte COMP-3, ASCII zoned) use
53//! specialized fast paths
54//! - **Branch prediction**: Manual hints mark error paths as unlikely
55//! - **Zero-allocation**: Scratch buffer variants avoid repeated allocations in loops
56//! - **Saturating arithmetic**: Prevents panics while maintaining correctness
57//!
58//! ## Encoding Formats
59//!
60//! ### Zoned Decimal Encoding
61//!
62//! Zoned decimals support two primary encoding formats:
63//!
64//! | Format | Zone Nibble | Example Digits | Sign Encoding |
65//! |---------|--------------|----------------|---------------|
66//! | ASCII | `0x3` | `0x30`-`0x39` | Overpunch or separate |
67//! | EBCDIC | `0xF` | `0xF0`-`0xF9` | Overpunch or separate |
68//!
69//! ### Packed Decimal Sign Nibbles
70//!
71//! COMP-3 uses the last nibble for sign encoding:
72//!
73//! | Sign | Nibble | Description |
74//! |------|---------|-------------|
75//! | Positive | `0xC`, `0xA`, `0xE`, `0xF` | Positive values |
76//! | Negative | `0xB`, `0xD` | Negative values |
77//! | Unsigned | `0xF` | Unsigned fields only |
78//!
79//! ### Binary Integer Widths
80//!
81//! Binary integers use the following width mappings:
82//!
83//! | Digits | Width | Bits | Range (signed) |
84//! |---------|--------|-------|----------------|
85//! | 1-4 | 2 bytes | 16 | -32,768 to 32,767 |
86//! | 5-9 | 4 bytes | 32 | -2,147,483,648 to 2,147,483,647 |
87//! | 10-18 | 8 bytes | 64 | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
88//!
89//! ## Examples
90//!
91//! ### Decoding a Zoned Decimal
92//!
93//! ```no_run
94//! use copybook_codec::numeric::{decode_zoned_decimal};
95//! use copybook_codec::options::Codepage;
96//!
97//! // ASCII zoned decimal: "123" = [0x31, 0x32, 0x33]
98//! let data = b"123";
99//! let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
100//! assert_eq!(result.to_string(), "123");
101//! # Ok::<(), copybook_core::Error>(())
102//! ```
103//!
104//! ### Encoding a Packed Decimal
105//!
106//! ```no_run
107//! use copybook_codec::numeric::{encode_packed_decimal};
108//!
109//! // Encode "123.45" as 7-digit COMP-3 with 2 decimal places
110//! let encoded = encode_packed_decimal("123.45", 7, 2, true)?;
111//! // Result: [0x12, 0x34, 0x5C] (12345 positive)
112//! # Ok::<(), copybook_core::Error>(())
113//! ```
114//!
115//! ## See Also
116//!
117//! - [`crate::zoned_overpunch`] - Zoned decimal overpunch encoding/decoding
118//! - [`crate::SmallDecimal`] - Decimal representation without floating-point precision loss
119//! - [`crate::memory::ScratchBuffers`] - Reusable buffers for zero-allocation processing
120
121use crate::memory::ScratchBuffers;
122use crate::options::Codepage;
123use copybook_core::{Error, ErrorCode, Result, SignPlacement, SignSeparateInfo};
124use std::convert::TryFrom;
125use tracing::warn;
126
127mod alphanumeric;
128mod binary;
129mod branch;
130mod decimal;
131mod float;
132pub mod overpunch;
133pub mod zoned;
134
135pub use alphanumeric::encode_alphanumeric;
136pub use binary::{
137 decode_binary_int, decode_binary_int_fast, encode_binary_int, get_binary_width_from_digits,
138 validate_explicit_binary_width,
139};
140use branch::{likely, unlikely};
141pub use decimal::{SmallDecimal, ZonedEncodingInfo};
142use decimal::{create_normalized_decimal, digit_from_value, scale_abs_to_u32};
143pub use float::*;
144pub use overpunch::{
145 ZeroSignPolicy, decode_ebcdic_overpunch_zone, decode_overpunch_byte,
146 encode_ebcdic_overpunch_zone, encode_overpunch_byte, get_all_valid_overpunch_bytes,
147 is_valid_overpunch,
148};
149pub use zoned::{ParseZonedEncodingFormatError, ZonedEncodingFormat};
150
151/// Nibble zones for ASCII/EBCDIC digits (high bits in zoned bytes).
152const ASCII_DIGIT_ZONE: u8 = 0x3; // ASCII '0'..'9' => 0x30..0x39
153const EBCDIC_DIGIT_ZONE: u8 = 0xF; // EBCDIC '0'..'9' => 0xF0..0xF9
154
155/// Decode a zoned decimal using the configured code page with detailed error context.
156///
157/// Decodes zoned decimal (PIC 9) fields where each digit is stored in a byte
158/// with a zone nibble and a digit nibble. The sign may be encoded via overpunch
159/// in the last byte's zone nibble.
160///
161/// # Arguments
162/// * `data` - Raw byte data containing the zoned decimal
163/// * `digits` - Number of digit characters (field length)
164/// * `scale` - Number of decimal places (can be negative for scaling)
165/// * `signed` - Whether the field is signed (true) or unsigned (false)
166/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
167/// * `blank_when_zero` - If true, all-space fields decode as zero
168///
169/// # Returns
170/// A `SmallDecimal` containing the decoded value
171///
172/// # Policy
173/// Applies the codec default: ASCII uses `ZeroSignPolicy::Positive`; EBCDIC zeros normalize via `ZeroSignPolicy::Preferred`.
174///
175/// # Errors
176/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
177/// * `CBKD411_ZONED_BAD_SIGN` - if the zoned digits, zones, or sign are invalid
178///
179/// # Examples
180///
181/// ## ASCII Zoned Decimal
182///
183/// ```no_run
184/// use copybook_codec::numeric::{decode_zoned_decimal};
185/// use copybook_codec::options::Codepage;
186///
187/// // ASCII "123" = [0x31, 0x32, 0x33]
188/// let data = b"123";
189/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
190/// assert_eq!(result.to_string(), "123");
191/// # Ok::<(), copybook_core::Error>(())
192/// ```
193///
194/// ## Signed ASCII Zoned Decimal (Overpunch)
195///
196/// ```no_run
197/// use copybook_codec::numeric::{decode_zoned_decimal};
198/// use copybook_codec::options::Codepage;
199///
200/// // ASCII "-123" with overpunch: [0x31, 0x32, 0x4D] (M = 3 with negative sign)
201/// let data = [0x31, 0x32, 0x4D];
202/// let result = decode_zoned_decimal(&data, 3, 0, true, Codepage::ASCII, false)?;
203/// assert_eq!(result.to_string(), "-123");
204/// # Ok::<(), copybook_core::Error>(())
205/// ```
206///
207/// ## EBCDIC Zoned Decimal
208///
209/// ```no_run
210/// use copybook_codec::numeric::{decode_zoned_decimal};
211/// use copybook_codec::options::Codepage;
212///
213/// // EBCDIC "123" = [0xF1, 0xF2, 0xF3]
214/// let data = [0xF1, 0xF2, 0xF3];
215/// let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, false)?;
216/// assert_eq!(result.to_string(), "123");
217/// # Ok::<(), copybook_core::Error>(())
218/// ```
219///
220/// ## Decimal Scale
221///
222/// ```no_run
223/// use copybook_codec::numeric::{decode_zoned_decimal};
224/// use copybook_codec::options::Codepage;
225///
226/// // "12.34" with 2 decimal places
227/// let data = b"1234";
228/// let result = decode_zoned_decimal(data, 4, 2, false, Codepage::ASCII, false)?;
229/// assert_eq!(result.to_string(), "12.34");
230/// # Ok::<(), copybook_core::Error>(())
231/// ```
232///
233/// ## BLANK WHEN ZERO
234///
235/// ```no_run
236/// use copybook_codec::numeric::{decode_zoned_decimal};
237/// use copybook_codec::options::Codepage;
238///
239/// // All spaces decode as zero when blank_when_zero is true
240/// let data = b" ";
241/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, true)?;
242/// assert_eq!(result.to_string(), "0");
243/// # Ok::<(), copybook_core::Error>(())
244/// ```
245///
246/// # See Also
247/// * [`decode_zoned_decimal_sign_separate`] - For SIGN SEPARATE fields
248/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
249/// * [`encode_zoned_decimal`] - For encoding zoned decimals
250#[inline]
251#[must_use = "Handle the Result or propagate the error"]
252pub fn decode_zoned_decimal(
253 data: &[u8],
254 digits: u16,
255 scale: i16,
256 signed: bool,
257 codepage: Codepage,
258 blank_when_zero: bool,
259) -> Result<SmallDecimal> {
260 if unlikely(data.len() != usize::from(digits)) {
261 return Err(Error::new(
262 ErrorCode::CBKD411_ZONED_BAD_SIGN,
263 "Zoned decimal data length mismatch".to_string(),
264 ));
265 }
266
267 // Check for BLANK WHEN ZERO (all spaces)
268 let is_all_spaces = data.iter().all(|&b| {
269 match codepage {
270 Codepage::ASCII => b == b' ',
271 _ => b == 0x40, // EBCDIC space
272 }
273 });
274
275 if is_all_spaces {
276 if blank_when_zero {
277 warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
278 // Track this warning in RunSummary
279 crate::lib_api::increment_warning_counter();
280 return Ok(SmallDecimal::zero(scale));
281 }
282 return Err(Error::new(
283 ErrorCode::CBKD411_ZONED_BAD_SIGN,
284 "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
285 ));
286 }
287
288 let mut value = 0i64;
289 let mut is_negative = false;
290 let expected_zone = match codepage {
291 Codepage::ASCII => ASCII_DIGIT_ZONE,
292 _ => EBCDIC_DIGIT_ZONE,
293 };
294
295 for (i, &byte) in data.iter().enumerate() {
296 if i == data.len() - 1 {
297 let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
298
299 if signed {
300 is_negative = negative;
301 } else {
302 let zone = (byte >> 4) & 0x0F;
303 let zone_label = match codepage {
304 Codepage::ASCII => "ASCII",
305 _ => "EBCDIC",
306 };
307 if zone != expected_zone {
308 return Err(Error::new(
309 ErrorCode::CBKD411_ZONED_BAD_SIGN,
310 format!(
311 "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
312 ),
313 ));
314 }
315 if negative {
316 return Err(Error::new(
317 ErrorCode::CBKD411_ZONED_BAD_SIGN,
318 "Unsigned zoned decimal contains negative overpunch",
319 ));
320 }
321 }
322
323 value = zoned_append_digit(value, digit, i)?;
324 } else {
325 let zone = (byte >> 4) & 0x0F;
326 let digit = byte & 0x0F;
327
328 if digit > 9 {
329 return Err(Error::new(
330 ErrorCode::CBKD411_ZONED_BAD_SIGN,
331 format!("Invalid digit nibble 0x{digit:X} at position {i}"),
332 ));
333 }
334
335 if zone != expected_zone {
336 let zone_label = match codepage {
337 Codepage::ASCII => "ASCII",
338 _ => "EBCDIC",
339 };
340 return Err(Error::new(
341 ErrorCode::CBKD411_ZONED_BAD_SIGN,
342 format!(
343 "Invalid {zone_label} zone 0x{zone:X} at position {i}, expected 0x{expected_zone:X}"
344 ),
345 ));
346 }
347
348 value = zoned_append_digit(value, digit, i)?;
349 }
350 }
351
352 let mut decimal = SmallDecimal::new(value, scale, is_negative);
353 decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
354 Ok(decimal)
355}
356
357/// Append one logical digit to a zoned-decimal magnitude without data loss.
358#[inline]
359fn zoned_append_digit(value: i64, digit: u8, position: usize) -> Result<i64> {
360 value
361 .checked_mul(10)
362 .and_then(|scaled| scaled.checked_add(i64::from(digit)))
363 .ok_or_else(|| {
364 Error::new(
365 ErrorCode::CBKD410_ZONED_OVERFLOW,
366 format!(
367 "Zoned decimal magnitude exceeds i64 capacity while appending digit {digit} at position {position}"
368 ),
369 )
370 })
371}
372
373/// Decode a zoned decimal field with SIGN SEPARATE clause
374///
375/// SIGN SEPARATE stores the sign in a separate byte rather than overpunching
376/// it in the zone portion of the last digit. The sign byte can be leading
377/// (before digits) or trailing (after digits).
378///
379/// # Arguments
380/// * `data` - Raw byte data (includes sign byte + digit bytes)
381/// * `digits` - Number of digit characters (not including sign byte)
382/// * `scale` - Decimal places (can be negative for scaling)
383/// * `sign_separate` - SIGN SEPARATE clause information (placement)
384/// * `codepage` - Character encoding (ASCII or EBCDIC)
385///
386/// # Returns
387/// A `SmallDecimal` containing the decoded value
388///
389/// # Errors
390/// Returns an error if data length is incorrect or sign byte is invalid.
391///
392/// # Examples
393///
394/// ## Leading Sign (ASCII)
395///
396/// ```no_run
397/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
398/// use copybook_codec::options::Codepage;
399/// use copybook_core::SignPlacement;
400/// use copybook_core::SignSeparateInfo;
401///
402/// // "+123" with leading sign: [0x2B, 0x31, 0x32, 0x33]
403/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
404/// let data = [b'+', b'1', b'2', b'3'];
405/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
406/// assert_eq!(result.to_string(), "123");
407/// # Ok::<(), copybook_core::Error>(())
408/// ```
409///
410/// ## Trailing Sign (ASCII)
411///
412/// ```no_run
413/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
414/// use copybook_codec::options::Codepage;
415/// use copybook_core::SignPlacement;
416/// use copybook_core::SignSeparateInfo;
417///
418/// // "-456" with trailing sign: [0x34, 0x35, 0x36, 0x2D]
419/// let sign_info = SignSeparateInfo { placement: SignPlacement::Trailing };
420/// let data = [b'4', b'5', b'6', b'-'];
421/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
422/// assert_eq!(result.to_string(), "-456");
423/// # Ok::<(), copybook_core::Error>(())
424/// ```
425///
426/// ## EBCDIC Leading Sign
427///
428/// ```no_run
429/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
430/// use copybook_codec::options::Codepage;
431/// use copybook_core::SignPlacement;
432/// use copybook_core::SignSeparateInfo;
433///
434/// // "+789" with leading EBCDIC sign: [0x4E, 0xF7, 0xF8, 0xF9]
435/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
436/// let data = [0x4E, 0xF7, 0xF8, 0xF9];
437/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::CP037)?;
438/// assert_eq!(result.to_string(), "789");
439/// # Ok::<(), copybook_core::Error>(())
440/// ```
441///
442/// # See Also
443/// * [`decode_zoned_decimal`] - For overpunch-encoded zoned decimals
444/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
445#[inline]
446#[must_use = "Handle the Result or propagate the error"]
447pub fn decode_zoned_decimal_sign_separate(
448 data: &[u8],
449 digits: u16,
450 scale: i16,
451 sign_separate: &SignSeparateInfo,
452 codepage: Codepage,
453) -> Result<SmallDecimal> {
454 // SIGN SEPARATE adds 1 byte for the sign
455 let expected_len = usize::from(digits) + 1;
456
457 if unlikely(data.len() != expected_len) {
458 return Err(Error::new(
459 ErrorCode::CBKD301_RECORD_TOO_SHORT,
460 format!(
461 "SIGN SEPARATE zoned decimal data length mismatch: expected {} bytes, got {}",
462 expected_len,
463 data.len()
464 ),
465 ));
466 }
467
468 // Determine sign byte and digit bytes based on placement
469 let (sign_byte, digit_bytes) = match sign_separate.placement {
470 SignPlacement::Leading => {
471 // Sign byte is first, digits follow
472 if data.is_empty() {
473 return Err(Error::new(
474 ErrorCode::CBKD301_RECORD_TOO_SHORT,
475 "SIGN SEPARATE field is empty",
476 ));
477 }
478 (data[0], &data[1..])
479 }
480 SignPlacement::Trailing => {
481 // Digits are first, sign byte is last
482 if data.is_empty() {
483 return Err(Error::new(
484 ErrorCode::CBKD301_RECORD_TOO_SHORT,
485 "SIGN SEPARATE field is empty",
486 ));
487 }
488 (data[data.len() - 1], &data[..data.len() - 1])
489 }
490 };
491
492 // Decode sign byte
493
494 let is_negative = if codepage.is_ascii() {
495 match sign_byte {
496 b'-' => true,
497
498 b'+' | b' ' | b'0' => false, // Space or zero means positive/unsigned
499
500 _ => {
501 return Err(Error::new(
502 ErrorCode::CBKD411_ZONED_BAD_SIGN,
503 format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (ASCII)"),
504 ));
505 }
506 }
507 } else {
508 // EBCDIC codepage (CP037, CP273, CP500, CP1047, CP1140)
509
510 match sign_byte {
511 0x60 => true, // EBCDIC '-'
512
513 0x4E | 0x40 | 0xF0 => false, // Space or zero means positive/unsigned
514
515 _ => {
516 return Err(Error::new(
517 ErrorCode::CBKD411_ZONED_BAD_SIGN,
518 format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (EBCDIC)"),
519 ));
520 }
521 }
522 };
523
524 // Decode digit bytes
525
526 let mut value: i64 = 0;
527
528 for &byte in digit_bytes {
529 let digit = if codepage.is_ascii() {
530 if !byte.is_ascii_digit() {
531 return Err(Error::new(
532 ErrorCode::CBKD301_RECORD_TOO_SHORT,
533 format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (ASCII)"),
534 ));
535 }
536
537 byte - b'0'
538 } else {
539 // EBCDIC digits are 0xF0-0xF9
540
541 if !(0xF0..=0xF9).contains(&byte) {
542 return Err(Error::new(
543 ErrorCode::CBKD301_RECORD_TOO_SHORT,
544 format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (EBCDIC)"),
545 ));
546 }
547
548 byte - 0xF0
549 };
550
551 value = value
552 .checked_mul(10)
553 .and_then(|v| v.checked_add(i64::from(digit)))
554 .ok_or_else(|| {
555 Error::new(
556 ErrorCode::CBKD410_ZONED_OVERFLOW,
557 format!("SIGN SEPARATE zoned decimal value overflow for {digits} digits"),
558 )
559 })?;
560 }
561
562 let mut decimal = SmallDecimal::new(value, scale, is_negative);
563 decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
564 Ok(decimal)
565}
566
567/// Encode a zoned decimal value with SIGN SEPARATE clause.
568///
569/// The SIGN SEPARATE clause places the sign character in a separate byte
570/// (leading or trailing) rather than overpunching the last digit.
571///
572/// Total encoded length = digits + 1 (for the separate sign byte).
573///
574/// # Arguments
575/// * `value` - String representation of the numeric value (e.g., "123", "-456.78")
576/// * `digits` - Number of digit positions in the field
577/// * `scale` - Number of implied decimal places
578/// * `sign_separate` - Sign placement information (leading or trailing)
579/// * `codepage` - Character encoding (determines sign byte encoding)
580/// * `buffer` - Output buffer (must be at least digits + 1 bytes)
581///
582/// # Errors
583/// Returns `CBKE530_SIGN_SEPARATE_ENCODE_ERROR` if the value cannot be encoded.
584#[inline]
585#[must_use = "Handle the Result or propagate the error"]
586pub fn encode_zoned_decimal_sign_separate(
587 value: &str,
588 digits: u16,
589 scale: i16,
590 sign_separate: &SignSeparateInfo,
591 codepage: Codepage,
592 buffer: &mut [u8],
593) -> Result<()> {
594 let expected_len = usize::from(digits) + 1;
595 if buffer.len() < expected_len {
596 return Err(Error::new(
597 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
598 format!(
599 "SIGN SEPARATE encode buffer too small: need {expected_len} bytes, got {}",
600 buffer.len()
601 ),
602 ));
603 }
604
605 // Parse value string to determine sign and digit characters
606 let trimmed = value.trim();
607 let (is_negative, abs_str) = if let Some(rest) = trimmed.strip_prefix('-') {
608 (true, rest)
609 } else if let Some(rest) = trimmed.strip_prefix('+') {
610 (false, rest)
611 } else {
612 (false, trimmed)
613 };
614
615 // Validate input characters before scaling
616 for ch in abs_str.chars() {
617 if !ch.is_ascii_digit() && ch != '.' {
618 return Err(Error::new(
619 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
620 format!("Unexpected character '{ch}' in numeric value '{value}'"),
621 ));
622 }
623 }
624
625 // Structural validation: reject ambiguous/empty numeric input
626 let dot_count = abs_str.chars().filter(|&c| c == '.').count();
627 let digit_count = abs_str.chars().filter(char::is_ascii_digit).count();
628
629 if digit_count == 0 {
630 return Err(Error::new(
631 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
632 format!("No digits found in numeric value '{value}'"),
633 ));
634 }
635 if dot_count > 1 {
636 return Err(Error::new(
637 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
638 format!("Multiple decimal points in numeric value '{value}'"),
639 ));
640 }
641 if scale <= 0 && dot_count == 1 {
642 return Err(Error::new(
643 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
644 format!("Unexpected decimal point for scale {scale} in value '{value}'"),
645 ));
646 }
647
648 // Build the scaled digit string
649 let scaled = build_scaled_digit_string(abs_str, scale);
650
651 // Pad with leading zeros or truncate to match digit count
652 let digits_usize = usize::from(digits);
653 let padded = match scaled.len().cmp(&digits_usize) {
654 std::cmp::Ordering::Less => {
655 format!("{scaled:0>digits_usize$}")
656 }
657 std::cmp::Ordering::Greater => {
658 return Err(Error::new(
659 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
660 format!(
661 "SIGN SEPARATE overflow: value requires {} digits but field allows {}",
662 scaled.len(),
663 digits_usize
664 ),
665 ));
666 }
667 std::cmp::Ordering::Equal => scaled,
668 };
669
670 // Determine sign byte and digit encoding based on codepage
671 let (sign_byte, digit_base): (u8, u8) = if codepage.is_ascii() {
672 (if is_negative { b'-' } else { b'+' }, b'0')
673 } else {
674 // EBCDIC codepages
675 (if is_negative { 0x60 } else { 0x4E }, 0xF0)
676 };
677
678 // Write digits to buffer
679 let digit_offset = match sign_separate.placement {
680 SignPlacement::Leading => {
681 buffer[0] = sign_byte;
682 1
683 }
684 SignPlacement::Trailing => 0,
685 };
686
687 for (i, byte) in padded.bytes().enumerate() {
688 let digit = byte.wrapping_sub(b'0');
689 if digit > 9 {
690 return Err(Error::new(
691 ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
692 format!("Invalid digit byte 0x{byte:02X} in value"),
693 ));
694 }
695 buffer[digit_offset + i] = digit_base + digit;
696 }
697
698 if matches!(sign_separate.placement, SignPlacement::Trailing) {
699 buffer[digits_usize] = sign_byte;
700 }
701
702 Ok(())
703}
704
705/// Build a digit string scaled to the given number of decimal places.
706///
707/// Splits the absolute value string at the decimal point (if present),
708/// pads or truncates the fractional part to `scale` digits, and concatenates.
709fn build_scaled_digit_string(abs_str: &str, scale: i16) -> String {
710 // Extract only digit characters (ignoring other formatting)
711 let digit_str: String = abs_str.chars().filter(char::is_ascii_digit).collect();
712
713 if scale <= 0 {
714 return digit_str;
715 }
716
717 let scale_usize = usize::try_from(scale).unwrap_or(0);
718 let (integer_part, fractional_part) = if let Some(pos) = abs_str.find('.') {
719 (&abs_str[..pos], &abs_str[pos + 1..])
720 } else {
721 (abs_str, "")
722 };
723
724 let int_digits: String = integer_part.chars().filter(char::is_ascii_digit).collect();
725 let frac_digits: String = fractional_part
726 .chars()
727 .filter(char::is_ascii_digit)
728 .collect();
729
730 // Pad or truncate fractional part to match scale
731 let padded_frac = if frac_digits.len() >= scale_usize {
732 frac_digits[..scale_usize].to_string()
733 } else {
734 format!("{frac_digits:0<scale_usize$}")
735 };
736 format!("{int_digits}{padded_frac}")
737}
738
739/// Decode zoned decimal field with encoding detection and preservation
740///
741/// Returns both the decoded decimal and encoding information for preservation.
742/// When `preserve_encoding` is true, analyzes the input data to detect
743/// whether it uses ASCII or EBCDIC encoding, and whether mixed encodings
744/// are present within the field.
745///
746/// # Arguments
747/// * `data` - Raw byte data containing the zoned decimal
748/// * `digits` - Number of digit characters (field length)
749/// * `scale` - Number of decimal places (can be negative for scaling)
750/// * `signed` - Whether the field is signed (true) or unsigned (false)
751/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
752/// * `blank_when_zero` - If true, all-space fields decode as zero
753/// * `preserve_encoding` - If true, detect and return encoding information
754///
755/// # Returns
756/// A tuple of (`SmallDecimal`, `Option<ZonedEncodingInfo>`) containing:
757/// - The decoded decimal value
758/// - Encoding information (if `preserve_encoding` was true)
759///
760/// # Policy
761/// Mirrors [`decode_zoned_decimal`], defaulting to preferred-zero handling for EBCDIC unless a preserved format dictates otherwise.
762///
763/// # Errors
764/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
765/// * `CBKD411_ZONED_BAD_SIGN` - if the zoned digits, zones, or sign are invalid
766/// * `CBKD413_ZONED_INVALID_ENCODING` - if preserved encoding zones are invalid
767/// * `CBKD414_ZONED_MIXED_ENCODING` - if preserved encoding mixes formats
768/// * `CBKD415_ZONED_ENCODING_AMBIGUOUS` - if preserved encoding cannot be detected
769///
770/// # Examples
771///
772/// ## Basic Decoding Without Preservation
773///
774/// ```no_run
775/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
776/// use copybook_codec::options::Codepage;
777///
778/// let data = b"123";
779/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
780/// data, 3, 0, false, Codepage::ASCII, false, false
781/// )?;
782/// assert_eq!(decimal.to_string(), "123");
783/// assert!(encoding_info.is_none());
784/// # Ok::<(), copybook_core::Error>(())
785/// ```
786///
787/// ## Encoding Detection
788///
789/// ```no_run
790/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
791/// use copybook_codec::options::Codepage;
792/// use copybook_codec::options::ZonedEncodingFormat;
793///
794/// let data = b"123";
795/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
796/// data, 3, 0, false, Codepage::ASCII, false, true
797/// )?;
798/// assert_eq!(decimal.to_string(), "123");
799/// let info = encoding_info.unwrap();
800/// assert_eq!(info.detected_format, ZonedEncodingFormat::Ascii);
801/// assert!(!info.has_mixed_encoding);
802/// # Ok::<(), copybook_core::Error>(())
803/// ```
804///
805/// # See Also
806/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
807/// * [`ZonedEncodingInfo`] - For encoding detection results
808#[inline]
809#[must_use = "Handle the Result or propagate the error"]
810pub fn decode_zoned_decimal_with_encoding(
811 data: &[u8],
812 digits: u16,
813 scale: i16,
814 signed: bool,
815 codepage: Codepage,
816 blank_when_zero: bool,
817 preserve_encoding: bool,
818) -> Result<(SmallDecimal, Option<ZonedEncodingInfo>)> {
819 if data.len() != usize::from(digits) {
820 return Err(Error::new(
821 ErrorCode::CBKD411_ZONED_BAD_SIGN,
822 format!(
823 "Zoned decimal data length {} doesn't match digits {}",
824 data.len(),
825 digits
826 ),
827 ));
828 }
829
830 // Check for BLANK WHEN ZERO (all spaces)
831 let is_all_spaces = data.iter().all(|&b| {
832 match codepage {
833 Codepage::ASCII => b == b' ',
834 _ => b == 0x40, // EBCDIC space
835 }
836 });
837
838 if is_all_spaces {
839 if blank_when_zero {
840 warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
841 crate::lib_api::increment_warning_counter();
842 return Ok((SmallDecimal::zero(scale), None));
843 }
844 return Err(Error::new(
845 ErrorCode::CBKD411_ZONED_BAD_SIGN,
846 "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
847 ));
848 }
849
850 // Detect encoding if preservation is enabled
851 let encoding_info = if preserve_encoding {
852 Some(ZonedEncodingInfo::detect_from_data(data)?)
853 } else {
854 None
855 };
856
857 // Reject mixed encoding and data with no determinable zoned encoding
858 if let Some(ref info) = encoding_info
859 && info.detected_format == ZonedEncodingFormat::Auto
860 {
861 let (code, message) = if info.has_mixed_encoding {
862 (
863 ErrorCode::CBKD414_ZONED_MIXED_ENCODING,
864 "Mixed ASCII/EBCDIC encoding detected within zoned decimal field",
865 )
866 } else {
867 (
868 ErrorCode::CBKD415_ZONED_ENCODING_AMBIGUOUS,
869 "Unable to determine ASCII or EBCDIC encoding for zoned decimal field",
870 )
871 };
872 return Err(Error::new(code, message));
873 }
874
875 let (value, is_negative) =
876 zoned_decode_digits_with_encoding(data, signed, codepage, preserve_encoding)?;
877
878 let mut decimal = SmallDecimal::new(value, scale, is_negative);
879 decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
880 Ok((decimal, encoding_info))
881}
882
883/// Internal helper to decode zoned decimal digits with encoding detection
884///
885/// This function handles the core logic of iterating through zoned decimal bytes,
886/// accumulating the numeric value, and optionally detecting/validating the encoding.
887///
888/// # Arguments
889/// * `data` - Raw byte data containing the zoned decimal
890/// * `signed` - Whether the field is signed
891/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
892/// * `preserve_encoding` - If true, validate consistent encoding throughout the field
893///
894/// # Returns
895/// A tuple of (`accumulated_value`, `is_negative`)
896///
897/// # Errors
898/// Returns `CBKD410_ZONED_OVERFLOW` if the magnitude exceeds `i64` capacity,
899/// or a typed zoned-format error if a digit, zone, or sign is invalid.
900#[inline]
901fn zoned_decode_digits_with_encoding(
902 data: &[u8],
903 signed: bool,
904 codepage: Codepage,
905 preserve_encoding: bool,
906) -> Result<(i64, bool)> {
907 let mut value = 0i64;
908 let mut is_negative = false;
909
910 for (index, &byte) in data.iter().enumerate() {
911 let zone = (byte >> 4) & 0x0F;
912
913 if index == data.len() - 1 {
914 let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
915
916 if signed {
917 is_negative = negative;
918 } else {
919 let zone_valid = if preserve_encoding {
920 matches!(zone, 0x3 | 0xF)
921 } else {
922 match codepage {
923 Codepage::ASCII => zone == 0x3,
924 _ => zone == 0xF,
925 }
926 };
927
928 if !zone_valid {
929 let message = if preserve_encoding {
930 format!(
931 "Invalid zone 0x{zone:X} in unsigned zoned decimal, expected 0x3 (ASCII) or 0xF (EBCDIC)"
932 )
933 } else {
934 let zone_label = zoned_zone_label(codepage);
935 format!(
936 "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
937 )
938 };
939 let code = if preserve_encoding {
940 ErrorCode::CBKD413_ZONED_INVALID_ENCODING
941 } else {
942 ErrorCode::CBKD411_ZONED_BAD_SIGN
943 };
944 return Err(Error::new(code, message));
945 }
946
947 if negative {
948 return Err(Error::new(
949 ErrorCode::CBKD411_ZONED_BAD_SIGN,
950 "Unsigned zoned decimal contains negative overpunch",
951 ));
952 }
953 }
954
955 value = zoned_append_digit(value, digit, index)?;
956 } else {
957 let digit = byte & 0x0F;
958 if digit > 9 {
959 return Err(Error::new(
960 ErrorCode::CBKD411_ZONED_BAD_SIGN,
961 format!("Invalid digit nibble 0x{digit:X} at position {index}"),
962 ));
963 }
964
965 if preserve_encoding {
966 match zone {
967 0x3 | 0xF => {}
968 _ => {
969 return Err(Error::new(
970 ErrorCode::CBKD413_ZONED_INVALID_ENCODING,
971 format!(
972 "Invalid zone 0x{zone:X} at position {index}, expected 0x3 (ASCII) or 0xF (EBCDIC)"
973 ),
974 ));
975 }
976 }
977 } else {
978 match codepage {
979 Codepage::ASCII => {
980 if zone != 0x3 {
981 return Err(Error::new(
982 ErrorCode::CBKD411_ZONED_BAD_SIGN,
983 format!(
984 "Invalid ASCII zone 0x{zone:X} at position {index}, expected 0x3"
985 ),
986 ));
987 }
988 }
989 _ => {
990 if zone != 0xF {
991 return Err(Error::new(
992 ErrorCode::CBKD411_ZONED_BAD_SIGN,
993 format!(
994 "Invalid EBCDIC zone 0x{zone:X} at position {index}, expected 0xF"
995 ),
996 ));
997 }
998 }
999 }
1000 }
1001
1002 value = zoned_append_digit(value, digit, index)?;
1003 }
1004 }
1005
1006 Ok((value, is_negative))
1007}
1008
1009/// Decode packed decimal (COMP-3) field with comprehensive error context
1010///
1011/// Decodes COMP-3 packed decimal format where each byte contains two decimal
1012/// digits (nibbles), with the last nibble containing the sign. This function
1013/// uses optimized fast paths for common enterprise data patterns.
1014///
1015/// # Arguments
1016/// * `data` - Raw byte data containing the packed decimal
1017/// * `digits` - Number of decimal digits in the field (1-18 supported)
1018/// * `scale` - Number of decimal places (can be negative for scaling)
1019/// * `signed` - Whether the field is signed (true) or unsigned (false)
1020///
1021/// # Returns
1022/// A `SmallDecimal` containing the decoded value
1023///
1024/// # Errors
1025/// Returns an error if the packed decimal data contains invalid nibbles.
1026/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
1027///
1028/// # Performance
1029/// This function uses specialized fast paths for common cases:
1030/// - 1-5 byte fields: Direct decoding with minimal validation
1031/// - Empty data: Immediate zero return
1032/// - Digits > 18: Error (maximum supported precision)
1033///
1034/// # Examples
1035///
1036/// ## Basic Positive Value
1037///
1038/// ```no_run
1039/// use copybook_codec::numeric::{decode_packed_decimal};
1040///
1041/// // "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
1042/// let data = [0x12, 0x3C];
1043/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1044/// assert_eq!(result.to_string(), "123");
1045/// # Ok::<(), copybook_core::Error>(())
1046/// ```
1047///
1048/// ## Negative Value
1049///
1050/// ```no_run
1051/// use copybook_codec::numeric::{decode_packed_decimal};
1052///
1053/// // "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
1054/// let data = [0x04, 0x56, 0xD];
1055/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1056/// assert_eq!(result.to_string(), "-456");
1057/// # Ok::<(), copybook_core::Error>(())
1058/// ```
1059///
1060/// ## Decimal Scale
1061///
1062/// ```no_run
1063/// use copybook_codec::numeric::{decode_packed_decimal};
1064///
1065/// // "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
1066/// let data = [0x12, 0x34, 0xC];
1067/// let result = decode_packed_decimal(&data, 4, 2, true)?;
1068/// assert_eq!(result.to_string(), "12.34");
1069/// # Ok::<(), copybook_core::Error>(())
1070/// ```
1071///
1072/// ## Unsigned Field
1073///
1074/// ```no_run
1075/// use copybook_codec::numeric::{decode_packed_decimal};
1076///
1077/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
1078/// let data = [0x07, 0x89, 0xF];
1079/// let result = decode_packed_decimal(&data, 3, 0, false)?;
1080/// assert_eq!(result.to_string(), "789");
1081/// # Ok::<(), copybook_core::Error>(())
1082/// ```
1083///
1084/// ## Zero Value
1085///
1086/// ```no_run
1087/// use copybook_codec::numeric::{decode_packed_decimal};
1088///
1089/// // Zero: [0x00, 0x0C]
1090/// let data = [0x00, 0x0C];
1091/// let result = decode_packed_decimal(&data, 2, 0, true)?;
1092/// assert_eq!(result.to_string(), "0");
1093/// # Ok::<(), copybook_core::Error>(())
1094/// ```
1095///
1096/// # See Also
1097/// * [`encode_packed_decimal`] - For encoding packed decimals
1098/// * [`decode_packed_decimal_with_scratch`] - For zero-allocation decoding
1099/// * [`decode_packed_decimal_to_string_with_scratch`] - For direct string output
1100#[inline]
1101#[must_use = "Handle the Result or propagate the error"]
1102pub fn decode_packed_decimal(
1103 data: &[u8],
1104 digits: u16,
1105 scale: i16,
1106 signed: bool,
1107) -> Result<SmallDecimal> {
1108 // CRITICAL PERFORMANCE OPTIMIZATION: Ultra-fast path with minimal safety overhead
1109 let expected_bytes = usize::from((digits + 1).div_ceil(2));
1110 // PERFORMANCE CRITICAL: Single branch validation optimized for happy path
1111 if likely(data.len() == expected_bytes && !data.is_empty() && digits <= 18) {
1112 // ULTRA-FAST PATH: Most common enterprise cases with minimal validation
1113 return decode_packed_decimal_fast_path(data, digits, scale, signed);
1114 }
1115
1116 // FALLBACK PATH: Full validation for edge cases
1117 if data.len() != expected_bytes {
1118 return Err(Error::new(
1119 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1120 "Packed decimal data length mismatch".to_string(),
1121 ));
1122 }
1123
1124 if data.is_empty() {
1125 return Ok(SmallDecimal::zero(scale));
1126 }
1127
1128 if digits > 18 {
1129 return Err(Error::new(
1130 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1131 format!(
1132 "COMP-3 field with {digits} digits exceeds maximum supported precision (18 digits max for current implementation)"
1133 ),
1134 ));
1135 }
1136
1137 // Delegate to ultra-fast path
1138 decode_packed_decimal_fast_path(data, digits, scale, signed)
1139}
1140
1141/// Ultra-optimized COMP-3 decoder for hot path performance
1142///
1143/// This function is highly optimized for the 95% case of enterprise COBOL processing
1144/// where COMP-3 fields are 1-5 bytes and well-formed. It selects the appropriate
1145/// specialized decoder based on the data length.
1146#[inline]
1147fn decode_packed_decimal_fast_path(
1148 data: &[u8],
1149 digits: u16,
1150 scale: i16,
1151 signed: bool,
1152) -> Result<SmallDecimal> {
1153 match data.len() {
1154 1 => decode_packed_fast_len1(data[0], digits, scale, signed),
1155 2 => decode_packed_fast_len2(data, digits, scale, signed),
1156 3 => decode_packed_fast_len3(data, scale, signed),
1157 _ => decode_packed_fast_general(data, digits, scale, signed),
1158 }
1159}
1160
1161/// Specialized COMP-3 decoder for 1-byte fields (1 digit)
1162///
1163/// # Arguments
1164/// * `byte` - The single byte of packed decimal data
1165/// * `digits` - Number of digits (should be 1)
1166/// * `scale` - Decimal scale
1167/// * `signed` - Whether the field is signed
1168#[inline]
1169fn decode_packed_fast_len1(
1170 byte: u8,
1171 digits: u16,
1172 scale: i16,
1173 signed: bool,
1174) -> Result<SmallDecimal> {
1175 let high_nibble = (byte >> 4) & 0x0F;
1176 let low_nibble = byte & 0x0F;
1177 let mut value = 0i64;
1178
1179 if !digits.is_multiple_of(2) {
1180 if unlikely(high_nibble > 9) {
1181 return Err(Error::new(
1182 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1183 "Invalid digit nibble in packed decimal".to_string(),
1184 ));
1185 }
1186 value = i64::from(high_nibble);
1187 }
1188
1189 if signed {
1190 let is_negative = match low_nibble {
1191 0xA | 0xC | 0xE | 0xF => false,
1192 0xB | 0xD => true,
1193 _ => {
1194 return Err(Error::new(
1195 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1196 "Invalid sign nibble in packed decimal".to_string(),
1197 ));
1198 }
1199 };
1200 return Ok(create_normalized_decimal(value, scale, is_negative));
1201 }
1202
1203 if unlikely(low_nibble != 0xF) {
1204 return Err(Error::new(
1205 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1206 "Invalid unsigned sign nibble, expected 0xF".to_string(),
1207 ));
1208 }
1209
1210 Ok(create_normalized_decimal(value, scale, false))
1211}
1212
1213/// Specialized COMP-3 decoder for 2-byte fields (2-3 digits)
1214///
1215/// # Arguments
1216/// * `data` - The 2 bytes of packed decimal data
1217/// * `digits` - Number of digits (2 or 3)
1218/// * `scale` - Decimal scale
1219/// * `signed` - Whether the field is signed
1220#[inline]
1221fn decode_packed_fast_len2(
1222 data: &[u8],
1223 digits: u16,
1224 scale: i16,
1225 signed: bool,
1226) -> Result<SmallDecimal> {
1227 let byte0 = data[0];
1228 let byte1 = data[1];
1229
1230 let d1 = (byte0 >> 4) & 0x0F;
1231 let d2 = byte0 & 0x0F;
1232 let d3 = (byte1 >> 4) & 0x0F;
1233 let sign_nibble = byte1 & 0x0F;
1234
1235 let value = if digits == 2 {
1236 if unlikely(d1 != 0) {
1237 return Err(Error::new(
1238 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1239 format!("Expected padding nibble 0 for 2-digit field, got 0x{d1:X}"),
1240 ));
1241 }
1242
1243 if unlikely(d2 > 9 || d3 > 9) {
1244 return Err(Error::new(
1245 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1246 "Invalid digit in 2-digit COMP-3 field".to_string(),
1247 ));
1248 }
1249
1250 i64::from(d2) * 10 + i64::from(d3)
1251 } else {
1252 if unlikely(d1 > 9 || d2 > 9 || d3 > 9) {
1253 return Err(Error::new(
1254 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1255 "Invalid digit in 3-digit COMP-3 field".to_string(),
1256 ));
1257 }
1258
1259 i64::from(d1) * 100 + i64::from(d2) * 10 + i64::from(d3)
1260 };
1261
1262 let is_negative = if signed {
1263 match sign_nibble {
1264 0xA | 0xC | 0xE | 0xF => false,
1265 0xB | 0xD => true,
1266 _ => {
1267 return Err(Error::new(
1268 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1269 "Invalid sign nibble in packed decimal".to_string(),
1270 ));
1271 }
1272 }
1273 } else {
1274 if unlikely(sign_nibble != 0xF) {
1275 return Err(Error::new(
1276 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1277 "Invalid unsigned sign nibble, expected 0xF".to_string(),
1278 ));
1279 }
1280 false
1281 };
1282
1283 Ok(create_normalized_decimal(value, scale, is_negative))
1284}
1285
1286/// Specialized COMP-3 decoder for 3-byte fields (4-5 digits)
1287///
1288/// # Arguments
1289/// * `data` - The 3 bytes of packed decimal data
1290/// * `scale` - Decimal scale
1291/// * `signed` - Whether the field is signed
1292#[inline]
1293fn decode_packed_fast_len3(data: &[u8], scale: i16, signed: bool) -> Result<SmallDecimal> {
1294 let byte0 = data[0];
1295 let byte1 = data[1];
1296 let byte2 = data[2];
1297
1298 let d1 = (byte0 >> 4) & 0x0F;
1299 let d2 = byte0 & 0x0F;
1300 let d3 = (byte1 >> 4) & 0x0F;
1301 let d4 = byte1 & 0x0F;
1302 let d5 = (byte2 >> 4) & 0x0F;
1303 let sign_nibble = byte2 & 0x0F;
1304
1305 if unlikely(d1 > 9 || d2 > 9 || d3 > 9 || d4 > 9 || d5 > 9) {
1306 return Err(Error::new(
1307 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1308 "Invalid digit in 3-byte COMP-3 field".to_string(),
1309 ));
1310 }
1311
1312 let value = i64::from(d1) * 10000
1313 + i64::from(d2) * 1000
1314 + i64::from(d3) * 100
1315 + i64::from(d4) * 10
1316 + i64::from(d5);
1317
1318 let is_negative = if signed {
1319 match sign_nibble {
1320 0xA | 0xC | 0xE | 0xF => false,
1321 0xB | 0xD => true,
1322 _ => {
1323 return Err(Error::new(
1324 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1325 "Invalid sign nibble in packed decimal".to_string(),
1326 ));
1327 }
1328 }
1329 } else {
1330 if unlikely(sign_nibble != 0xF) {
1331 return Err(Error::new(
1332 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1333 "Invalid unsigned sign nibble, expected 0xF".to_string(),
1334 ));
1335 }
1336 false
1337 };
1338
1339 Ok(create_normalized_decimal(value, scale, is_negative))
1340}
1341
1342/// General-purpose COMP-3 decoder for fields longer than 3 bytes
1343///
1344/// Handles multi-byte packed decimal decoding with support for padding
1345/// nibbles and variable digit counts.
1346///
1347/// # Arguments
1348/// * `data` - The packed decimal data bytes
1349/// * `digits` - Number of decimal digits in the field
1350/// * `scale` - Decimal scale
1351/// * `signed` - Whether the field is signed
1352#[inline]
1353fn decode_packed_fast_general(
1354 data: &[u8],
1355 digits: u16,
1356 scale: i16,
1357 signed: bool,
1358) -> Result<SmallDecimal> {
1359 let total_nibbles = digits + 1;
1360 let has_padding = (total_nibbles & 1) == 1;
1361 let digit_count = usize::from(digits);
1362
1363 let Some((last_byte, prefix_bytes)) = data.split_last() else {
1364 return Err(Error::new(
1365 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1366 "Packed decimal data is empty".to_string(),
1367 ));
1368 };
1369 let mut value = 0i64;
1370 let mut digit_pos = 0;
1371
1372 for &byte in prefix_bytes {
1373 let high_nibble = (byte >> 4) & 0x0F;
1374 let low_nibble = byte & 0x0F;
1375
1376 if likely(!(digit_pos == 0 && has_padding)) {
1377 if unlikely(high_nibble > 9) {
1378 return Err(Error::new(
1379 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1380 "Invalid digit nibble".to_string(),
1381 ));
1382 }
1383 value = value * 10 + i64::from(high_nibble);
1384 digit_pos += 1;
1385 }
1386
1387 if unlikely(low_nibble > 9) {
1388 return Err(Error::new(
1389 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1390 "Invalid digit nibble".to_string(),
1391 ));
1392 }
1393 value = value * 10 + i64::from(low_nibble);
1394 digit_pos += 1;
1395 }
1396
1397 let last_high = (*last_byte >> 4) & 0x0F;
1398 let sign_nibble = *last_byte & 0x0F;
1399
1400 if likely(digit_pos < digit_count) {
1401 if unlikely(last_high > 9) {
1402 return Err(Error::new(
1403 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1404 "Invalid digit nibble".to_string(),
1405 ));
1406 }
1407 value = value * 10 + i64::from(last_high);
1408 }
1409
1410 let is_negative = if signed {
1411 match sign_nibble {
1412 0xA | 0xC | 0xE | 0xF => false,
1413 0xB | 0xD => true,
1414 _ => {
1415 return Err(Error::new(
1416 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1417 "Invalid sign nibble".to_string(),
1418 ));
1419 }
1420 }
1421 } else {
1422 if unlikely(sign_nibble != 0xF) {
1423 return Err(Error::new(
1424 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1425 "Invalid unsigned sign nibble".to_string(),
1426 ));
1427 }
1428 false
1429 };
1430
1431 Ok(create_normalized_decimal(value, scale, is_negative))
1432}
1433
1434/// Encode a zoned decimal using the configured code page defaults.
1435///
1436/// Encodes decimal values to zoned decimal format (PIC 9) where each digit is stored
1437/// in a byte with a zone nibble and a digit nibble. For signed fields, the
1438/// last byte uses overpunch encoding for the sign.
1439///
1440/// # Arguments
1441/// * `value` - String representation of the decimal value to encode
1442/// * `digits` - Number of digit characters (field length)
1443/// * `scale` - Number of decimal places (can be negative for scaling)
1444/// * `signed` - Whether the field is signed (true) or unsigned (false)
1445/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1446///
1447/// # Returns
1448/// A vector of bytes containing the encoded zoned decimal
1449///
1450/// # Policy
1451/// Applies `ZeroSignPolicy::Positive` for ASCII and `ZeroSignPolicy::Preferred` for EBCDIC when no overrides are provided.
1452///
1453/// # Errors
1454/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
1455///
1456/// # Examples
1457///
1458/// ## Basic ASCII Encoding
1459///
1460/// ```no_run
1461/// use copybook_codec::numeric::{encode_zoned_decimal};
1462/// use copybook_codec::options::Codepage;
1463///
1464/// // Encode "123" as ASCII zoned decimal
1465/// let encoded = encode_zoned_decimal("123", 3, 0, false, Codepage::ASCII)?;
1466/// assert_eq!(encoded, b"123"); // [0x31, 0x32, 0x33]
1467/// # Ok::<(), copybook_core::Error>(())
1468/// ```
1469///
1470/// ## Signed ASCII Encoding (Overpunch)
1471///
1472/// ```no_run
1473/// use copybook_codec::numeric::{encode_zoned_decimal};
1474/// use copybook_codec::options::Codepage;
1475///
1476/// // Encode "-456" with overpunch sign
1477/// let encoded = encode_zoned_decimal("-456", 3, 0, true, Codepage::ASCII)?;
1478/// // Last byte 0x4D = 'M' = digit 3 with negative sign
1479/// assert_eq!(encoded, [0x34, 0x35, 0x4D]);
1480/// # Ok::<(), copybook_core::Error>(())
1481/// ```
1482///
1483/// ## EBCDIC Encoding
1484///
1485/// ```no_run
1486/// use copybook_codec::numeric::{encode_zoned_decimal};
1487/// use copybook_codec::options::Codepage;
1488///
1489/// // Encode "789" as EBCDIC zoned decimal
1490/// let encoded = encode_zoned_decimal("789", 3, 0, false, Codepage::CP037)?;
1491/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
1492/// # Ok::<(), copybook_core::Error>(())
1493/// ```
1494///
1495/// ## Decimal Scale
1496///
1497/// ```no_run
1498/// use copybook_codec::numeric::{encode_zoned_decimal};
1499/// use copybook_codec::options::Codepage;
1500///
1501/// // Encode "12.34" with 2 decimal places
1502/// let encoded = encode_zoned_decimal("12.34", 4, 2, false, Codepage::ASCII)?;
1503/// assert_eq!(encoded, b"1234"); // [0x31, 0x32, 0x33, 0x34]
1504/// # Ok::<(), copybook_core::Error>(())
1505/// ```
1506///
1507/// # See Also
1508/// * [`encode_zoned_decimal_with_format`] - For encoding with explicit format
1509/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
1510/// * [`encode_zoned_decimal_with_bwz`] - For encoding with BLANK WHEN ZERO support
1511/// * [`decode_zoned_decimal`] - For decoding zoned decimals
1512#[inline]
1513#[must_use = "Handle the Result or propagate the error"]
1514pub fn encode_zoned_decimal(
1515 value: &str,
1516 digits: u16,
1517 scale: i16,
1518 signed: bool,
1519 codepage: Codepage,
1520) -> Result<Vec<u8>> {
1521 let zero_policy = if codepage.is_ascii() {
1522 ZeroSignPolicy::Positive
1523 } else {
1524 ZeroSignPolicy::Preferred
1525 };
1526
1527 encode_zoned_decimal_with_format_and_policy(
1528 value,
1529 digits,
1530 scale,
1531 signed,
1532 codepage,
1533 None,
1534 zero_policy,
1535 )
1536}
1537
1538/// Encode a zoned decimal using an explicit encoding override when supplied.
1539///
1540/// Encodes zoned decimal values with an explicit encoding format (ASCII or EBCDIC).
1541/// When `encoding_override` is provided, it takes precedence over the codepage default.
1542/// When `Auto` is specified, the codepage default is used.
1543///
1544/// # Arguments
1545/// * `value` - String representation of the decimal value to encode
1546/// * `digits` - Number of digit characters (field length)
1547/// * `scale` - Number of decimal places (can be negative for scaling)
1548/// * `signed` - Whether the field is signed (true) or unsigned (false)
1549/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1550/// * `encoding_override` - Optional explicit encoding format (ASCII/EBCDIC/Auto)
1551///
1552/// # Returns
1553/// A vector of bytes containing the encoded zoned decimal
1554///
1555/// # Policy
1556/// Resolves `ZeroSignPolicy` from `encoding_override` first; when unset or `Auto`, falls back to the code page defaults.
1557///
1558/// # Errors
1559/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
1560///
1561/// # Examples
1562///
1563/// ## ASCII Encoding (Explicit)
1564///
1565/// ```no_run
1566/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
1567/// use copybook_codec::options::Codepage;
1568/// use copybook_codec::options::ZonedEncodingFormat;
1569///
1570/// // Encode "123" with explicit ASCII encoding
1571/// let encoded = encode_zoned_decimal_with_format(
1572/// "123", 3, 0, false, Codepage::ASCII, Some(ZonedEncodingFormat::Ascii)
1573/// )?;
1574/// assert_eq!(encoded, b"123");
1575/// # Ok::<(), copybook_core::Error>(())
1576/// ```
1577///
1578/// ## EBCDIC Encoding (Explicit)
1579///
1580/// ```no_run
1581/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
1582/// use copybook_codec::options::Codepage;
1583/// use copybook_codec::options::ZonedEncodingFormat;
1584///
1585/// // Encode "789" with explicit EBCDIC encoding
1586/// let encoded = encode_zoned_decimal_with_format(
1587/// "789", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Ebcdic)
1588/// )?;
1589/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
1590/// # Ok::<(), copybook_core::Error>(())
1591/// ```
1592///
1593/// ## Auto Encoding (Codepage Default)
1594///
1595/// ```no_run
1596/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
1597/// use copybook_codec::options::Codepage;
1598/// use copybook_codec::options::ZonedEncodingFormat;
1599///
1600/// // Encode "456" with Auto encoding (uses EBCDIC default for CP037)
1601/// let encoded = encode_zoned_decimal_with_format(
1602/// "456", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Auto)
1603/// )?;
1604/// assert_eq!(encoded, [0xF4, 0xF5, 0xF6]);
1605/// # Ok::<(), copybook_core::Error>(())
1606/// ```
1607///
1608/// # See Also
1609/// * [`encode_zoned_decimal`] - For encoding with codepage defaults
1610/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
1611#[inline]
1612#[must_use = "Handle the Result or propagate the error"]
1613pub fn encode_zoned_decimal_with_format(
1614 value: &str,
1615 digits: u16,
1616 scale: i16,
1617 signed: bool,
1618 codepage: Codepage,
1619 encoding_override: Option<ZonedEncodingFormat>,
1620) -> Result<Vec<u8>> {
1621 let zero_policy = match encoding_override {
1622 Some(ZonedEncodingFormat::Ascii) => ZeroSignPolicy::Positive,
1623 Some(ZonedEncodingFormat::Ebcdic) => ZeroSignPolicy::Preferred,
1624 Some(ZonedEncodingFormat::Auto) | None => {
1625 if codepage.is_ascii() {
1626 ZeroSignPolicy::Positive
1627 } else {
1628 ZeroSignPolicy::Preferred
1629 }
1630 }
1631 };
1632
1633 encode_zoned_decimal_with_format_and_policy(
1634 value,
1635 digits,
1636 scale,
1637 signed,
1638 codepage,
1639 encoding_override,
1640 zero_policy,
1641 )
1642}
1643
1644/// Encode a zoned decimal using a caller-resolved format and zero-sign policy.
1645///
1646/// This is the lowest-level zoned decimal encoder. The caller supplies both the
1647/// encoding format override (ASCII vs EBCDIC) and the zero-sign policy, which
1648/// together govern how the sign nibble of the last byte is produced. Higher-level
1649/// wrappers such as [`encode_zoned_decimal`] and [`encode_zoned_decimal_with_format`]
1650/// resolve these parameters from codec defaults and then delegate here.
1651///
1652/// # Arguments
1653/// * `value` - String representation of the decimal value to encode (e.g. `"123"`, `"-45.67"`)
1654/// * `digits` - Number of digit positions in the COBOL field (PIC digit count)
1655/// * `scale` - Number of implied decimal places (can be negative for scaling)
1656/// * `signed` - Whether the field carries a sign (PIC S9 vs PIC 9)
1657/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
1658/// * `encoding_override` - Explicit format override; `None` falls back to codepage default
1659/// * `zero_policy` - How the sign nibble is encoded for zero values
1660///
1661/// # Returns
1662/// A vector of bytes containing the encoded zoned decimal in the target encoding.
1663///
1664/// # Policy
1665/// Callers provide the resolved policy in precedence order:
1666/// override → preserved metadata → preferred for the target code page.
1667///
1668/// # Errors
1669/// * `CBKE510_NUMERIC_OVERFLOW` - if the value is too large for the digit count
1670/// * `CBKE501_JSON_TYPE_MISMATCH` - if the input contains non-digit characters
1671///
1672/// # See Also
1673/// * [`encode_zoned_decimal`] - Convenience wrapper that resolves policy from the codepage
1674/// * [`encode_zoned_decimal_with_format`] - Accepts a format override without an explicit policy
1675/// * [`encode_zoned_decimal_with_bwz`] - Adds BLANK WHEN ZERO support
1676#[inline]
1677#[must_use = "Handle the Result or propagate the error"]
1678pub fn encode_zoned_decimal_with_format_and_policy(
1679 value: &str,
1680 digits: u16,
1681 scale: i16,
1682 signed: bool,
1683 codepage: Codepage,
1684 encoding_override: Option<ZonedEncodingFormat>,
1685 zero_policy: ZeroSignPolicy,
1686) -> Result<Vec<u8>> {
1687 // Parse the input value with scale validation (NORMATIVE)
1688 let decimal = SmallDecimal::from_str(value, scale)?;
1689
1690 // Convert to string representation of digits
1691 let abs_value = decimal.value.abs();
1692 let width = usize::from(digits);
1693 let digit_str = format!("{abs_value:0width$}");
1694
1695 if digit_str.len() > width {
1696 return Err(Error::new(
1697 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
1698 format!("Value too large for {digits} digits"),
1699 ));
1700 }
1701
1702 // Determine the encoding format to use
1703 // Precedence: explicit override > codepage default
1704 let mut target_format = encoding_override.unwrap_or(match codepage {
1705 Codepage::ASCII => ZonedEncodingFormat::Ascii,
1706 _ => ZonedEncodingFormat::Ebcdic,
1707 });
1708 if target_format == ZonedEncodingFormat::Auto {
1709 target_format = if codepage.is_ascii() {
1710 ZonedEncodingFormat::Ascii
1711 } else {
1712 ZonedEncodingFormat::Ebcdic
1713 };
1714 }
1715
1716 let mut result = Vec::with_capacity(width);
1717 let digit_bytes = digit_str.as_bytes();
1718
1719 // Encode each digit
1720 for (i, &ascii_digit) in digit_bytes.iter().enumerate() {
1721 let digit = ascii_digit - b'0';
1722 if digit > 9 {
1723 return Err(Error::new(
1724 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1725 format!("Invalid digit character: {}", ascii_digit as char),
1726 ));
1727 }
1728
1729 if i == digit_bytes.len() - 1 && signed {
1730 if target_format == ZonedEncodingFormat::Ascii {
1731 let overpunch_byte = encode_overpunch_byte(
1732 digit,
1733 decimal.negative,
1734 Codepage::ASCII,
1735 ZeroSignPolicy::Positive,
1736 )?;
1737 result.push(overpunch_byte);
1738 } else {
1739 let encode_codepage = if codepage == Codepage::ASCII {
1740 Codepage::CP037
1741 } else {
1742 codepage
1743 };
1744 let overpunch_byte =
1745 encode_overpunch_byte(digit, decimal.negative, encode_codepage, zero_policy)?;
1746 result.push(overpunch_byte);
1747 }
1748 } else {
1749 let zone = match target_format {
1750 ZonedEncodingFormat::Ascii => ASCII_DIGIT_ZONE,
1751 _ => EBCDIC_DIGIT_ZONE,
1752 };
1753 result.push((zone << 4) | digit);
1754 }
1755 }
1756
1757 Ok(result)
1758}
1759
1760/// Encode packed decimal (COMP-3) field
1761///
1762/// Encodes decimal values to COMP-3 packed decimal format where each byte contains
1763/// two decimal digits (nibbles), with the last nibble containing the sign.
1764/// This function is optimized for high-throughput enterprise data processing.
1765///
1766/// # Arguments
1767/// * `value` - String representation of the decimal value to encode
1768/// * `digits` - Number of decimal digits in the field (1-18 supported)
1769/// * `scale` - Number of decimal places (can be negative for scaling)
1770/// * `signed` - Whether the field is signed (true) or unsigned (false)
1771///
1772/// # Returns
1773/// A vector of bytes containing the encoded packed decimal
1774///
1775/// # Errors
1776/// Returns an error if the value cannot be encoded as a packed decimal with the specified parameters.
1777///
1778/// # Performance
1779/// This function uses optimized digit extraction to avoid `format!()` allocation overhead.
1780///
1781/// # Examples
1782///
1783/// ## Basic Positive Value
1784///
1785/// ```no_run
1786/// use copybook_codec::numeric::{encode_packed_decimal};
1787///
1788/// // Encode "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
1789/// let encoded = encode_packed_decimal("123", 3, 0, true)?;
1790/// assert_eq!(encoded, [0x12, 0x3C]);
1791/// # Ok::<(), copybook_core::Error>(())
1792/// ```
1793///
1794/// ## Negative Value
1795///
1796/// ```no_run
1797/// use copybook_codec::numeric::{encode_packed_decimal};
1798///
1799/// // Encode "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
1800/// let encoded = encode_packed_decimal("-456", 3, 0, true)?;
1801/// assert_eq!(encoded, [0x04, 0x56, 0xD]);
1802/// # Ok::<(), copybook_core::Error>(())
1803/// ```
1804///
1805/// ## Decimal Scale
1806///
1807/// ```no_run
1808/// use copybook_codec::numeric::{encode_packed_decimal};
1809///
1810/// // Encode "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
1811/// let encoded = encode_packed_decimal("12.34", 4, 2, true)?;
1812/// assert_eq!(encoded, [0x12, 0x34, 0xC]);
1813/// # Ok::<(), copybook_core::Error>(())
1814/// ```
1815///
1816/// ## Unsigned Field
1817///
1818/// ```no_run
1819/// use copybook_codec::numeric::{encode_packed_decimal};
1820///
1821/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
1822/// let encoded = encode_packed_decimal("789", 3, 0, false)?;
1823/// assert_eq!(encoded, [0x07, 0x89, 0xF]);
1824/// # Ok::<(), copybook_core::Error>(())
1825/// ```
1826///
1827/// ## Zero Value
1828///
1829/// ```no_run
1830/// use copybook_codec::numeric::{encode_packed_decimal};
1831///
1832/// // Zero: [0x00, 0x0C]
1833/// let encoded = encode_packed_decimal("0", 2, 0, true)?;
1834/// assert_eq!(encoded, [0x00, 0x0C]);
1835/// # Ok::<(), copybook_core::Error>(())
1836/// ```
1837///
1838/// # See Also
1839/// * [`decode_packed_decimal`] - For decoding packed decimals
1840/// * [`encode_packed_decimal_with_scratch`] - For zero-allocation encoding
1841#[inline]
1842#[must_use = "Handle the Result or propagate the error"]
1843pub fn encode_packed_decimal(
1844 value: &str,
1845 digits: u16,
1846 scale: i16,
1847 signed: bool,
1848) -> Result<Vec<u8>> {
1849 // Parse the input value with scale validation (NORMATIVE)
1850 let decimal = SmallDecimal::from_str(value, scale)?;
1851
1852 // CRITICAL PERFORMANCE OPTIMIZATION: Avoid format!() allocation
1853 // Direct integer-to-digits conversion for massive speedup
1854 let abs_value = decimal.value.abs();
1855
1856 // Fast path for zero
1857 if abs_value == 0 {
1858 let expected_bytes = usize::from((digits + 1).div_ceil(2));
1859 let mut result = vec![0u8; expected_bytes];
1860 // Set sign in last byte
1861 let sign_nibble = if signed {
1862 if decimal.negative { 0x0D } else { 0x0C }
1863 } else {
1864 0x0F
1865 };
1866 result[expected_bytes - 1] = sign_nibble;
1867 return Ok(result);
1868 }
1869
1870 // Pre-allocate digit buffer on stack for speed (up to 18 digits for i64::MAX)
1871 let mut digit_buffer: [u8; 20] = [0; 20];
1872 let mut digit_count = 0;
1873 let mut temp_value = abs_value;
1874
1875 // Extract digits in reverse order using fast division
1876 while temp_value > 0 {
1877 digit_buffer[digit_count] = digit_from_value(temp_value % 10);
1878 temp_value /= 10;
1879 digit_count += 1;
1880 }
1881
1882 // Validate digit count
1883 let digits_usize = usize::from(digits);
1884 if unlikely(digit_count > digits_usize) {
1885 return Err(Error::new(
1886 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
1887 format!("Value too large for {digits} digits"),
1888 ));
1889 }
1890
1891 let expected_bytes = usize::from((digits + 1).div_ceil(2));
1892 let mut result = Vec::with_capacity(expected_bytes);
1893
1894 // CRITICAL FIX: Handle digit positioning correctly for even/odd digit counts
1895 // For packed decimal, we have:
1896 // - Total nibbles needed: digits + 1 (for sign)
1897 // - If digits is even: first nibble is padding (0), then digits, then sign
1898 // - If digits is odd: no padding, digits fill completely, then sign
1899
1900 let has_padding = digits.is_multiple_of(2); // Even digit count requires padding
1901 let total_nibbles = digits_usize + 1 + usize::from(has_padding);
1902
1903 for byte_idx in 0..expected_bytes {
1904 let mut byte_val = 0u8;
1905
1906 // Calculate which nibbles belong to this byte
1907 let nibble_offset = byte_idx * 2;
1908
1909 // High nibble
1910 let high_nibble_idx = nibble_offset;
1911 if high_nibble_idx < total_nibbles - 1 {
1912 // Not the sign nibble
1913 if has_padding && high_nibble_idx == 0 {
1914 // First nibble is padding for even digit count
1915 byte_val |= 0x00 << 4;
1916 } else {
1917 // Calculate which digit this represents
1918 let digit_idx = if has_padding {
1919 high_nibble_idx - 1
1920 } else {
1921 high_nibble_idx
1922 };
1923
1924 // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
1925 // For field width of 'digits', actual digits should occupy the rightmost positions
1926 if digit_idx >= (digits_usize - digit_count) {
1927 // This position should contain an actual digit
1928 let actual_digit_idx = digit_idx - (digits_usize - digit_count);
1929 if actual_digit_idx < digit_count {
1930 // Digits are stored in reverse order (least significant first)
1931 let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
1932 let digit = digit_buffer[digit_pos_from_right];
1933 byte_val |= digit << 4;
1934 }
1935 }
1936 // else: leading zero for large digit field (byte_val already initialized to 0)
1937 }
1938 }
1939
1940 // Low nibble
1941 let low_nibble_idx = nibble_offset + 1;
1942 if low_nibble_idx == total_nibbles - 1 {
1943 // This is the sign nibble
1944 byte_val |= if signed {
1945 if decimal.negative { 0x0D } else { 0x0C }
1946 } else {
1947 0x0F
1948 };
1949 } else if low_nibble_idx < total_nibbles - 1 {
1950 // Calculate which digit this represents
1951 let digit_idx = if has_padding {
1952 low_nibble_idx - 1
1953 } else {
1954 low_nibble_idx
1955 };
1956
1957 // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
1958 // For field width of 'digits', actual digits should occupy the rightmost positions
1959 if digit_idx >= (digits_usize - digit_count) {
1960 // This position should contain an actual digit
1961 let actual_digit_idx = digit_idx - (digits_usize - digit_count);
1962 if actual_digit_idx < digit_count {
1963 // Digits are stored in reverse order (least significant first)
1964 let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
1965 let digit = digit_buffer[digit_pos_from_right];
1966 byte_val |= digit;
1967 }
1968 }
1969 // else: leading zero for large digit field (byte_val already initialized to 0)
1970 }
1971
1972 result.push(byte_val);
1973 }
1974
1975 Ok(result)
1976}
1977
1978/// Determine whether a value should be encoded as all spaces under the
1979/// COBOL `BLANK WHEN ZERO` clause.
1980///
1981/// Returns `true` when `bwz_encode` is enabled **and** the string value
1982/// represents zero (including decimal zeros such as `"0.00"`). Callers
1983/// use this check before encoding to decide whether to emit a
1984/// space-filled field instead of the normal numeric encoding.
1985///
1986/// # Arguments
1987/// * `value` - String representation of the numeric value to test
1988/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active for this field
1989///
1990/// # Returns
1991/// `true` if the field should be encoded as all spaces; `false` otherwise.
1992///
1993/// # Examples
1994///
1995/// ```
1996/// use copybook_codec::numeric::should_encode_as_blank_when_zero;
1997///
1998/// assert!(should_encode_as_blank_when_zero("0", true));
1999/// assert!(should_encode_as_blank_when_zero("0.00", true));
2000/// assert!(!should_encode_as_blank_when_zero("42", true));
2001/// assert!(!should_encode_as_blank_when_zero("0", false)); // BWZ disabled
2002/// ```
2003///
2004/// # See Also
2005/// * [`encode_zoned_decimal_with_bwz`] - Uses this function to apply the BWZ policy
2006#[inline]
2007#[must_use]
2008pub fn should_encode_as_blank_when_zero(value: &str, bwz_encode: bool) -> bool {
2009 if !bwz_encode {
2010 return false;
2011 }
2012
2013 // Check if value is zero (with any scale)
2014 let trimmed = value.trim();
2015 if trimmed.is_empty() || trimmed == "0" {
2016 return true;
2017 }
2018
2019 // Check for decimal zero (0.00, 0.000, etc.)
2020 if let Some(dot_pos) = trimmed.find('.') {
2021 let integer_part = &trimmed[..dot_pos];
2022 let fractional_part = &trimmed[dot_pos + 1..];
2023
2024 if integer_part == "0" && fractional_part.chars().all(|c| c == '0') {
2025 return true;
2026 }
2027 }
2028
2029 false
2030}
2031
2032/// Encode a zoned decimal with COBOL `BLANK WHEN ZERO` support.
2033///
2034/// When `bwz_encode` is `true` and the value is zero (including decimal zeros
2035/// like `"0.00"`), the entire field is filled with space bytes (ASCII `0x20`
2036/// or EBCDIC `0x40`). Otherwise, encoding delegates to [`encode_zoned_decimal`].
2037///
2038/// # Arguments
2039/// * `value` - String representation of the decimal value to encode
2040/// * `digits` - Number of digit positions in the COBOL field
2041/// * `scale` - Number of implied decimal places
2042/// * `signed` - Whether the field carries a sign
2043/// * `codepage` - Target character encoding
2044/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active
2045///
2046/// # Returns
2047/// A vector of bytes containing the encoded zoned decimal, or all-space bytes
2048/// when the BWZ policy triggers.
2049///
2050/// # Errors
2051/// Returns an error if the value cannot be represented in the target zoned
2052/// decimal format (delegates error handling to [`encode_zoned_decimal`]).
2053///
2054/// # See Also
2055/// * [`should_encode_as_blank_when_zero`] - The predicate used to test zero values
2056/// * [`encode_zoned_decimal`] - Non-BWZ zoned decimal encoding
2057#[inline]
2058#[must_use = "Handle the Result or propagate the error"]
2059pub fn encode_zoned_decimal_with_bwz(
2060 value: &str,
2061 digits: u16,
2062 scale: i16,
2063 signed: bool,
2064 codepage: Codepage,
2065 bwz_encode: bool,
2066) -> Result<Vec<u8>> {
2067 // Check BWZ policy first
2068 if should_encode_as_blank_when_zero(value, bwz_encode) {
2069 let space_byte = match codepage {
2070 Codepage::ASCII => b' ',
2071 _ => 0x40, // EBCDIC space
2072 };
2073 return Ok(vec![space_byte; usize::from(digits)]);
2074 }
2075
2076 encode_zoned_decimal(value, digits, scale, signed, codepage)
2077}
2078
2079/// Get the space byte value for a given codepage
2080///
2081/// Returns the appropriate space character byte for ASCII or EBCDIC codepages.
2082///
2083/// # Arguments
2084/// * `codepage` - The target codepage
2085///
2086/// # Returns
2087/// * `0x20` (ASCII space) for ASCII codepage
2088/// * `0x40` (EBCDIC space) for EBCDIC codepages
2089///
2090/// # Examples
2091/// ```
2092/// use copybook_codec::options::Codepage;
2093/// # fn zoned_space_byte(codepage: Codepage) -> u8 {
2094/// # match codepage {
2095/// # Codepage::ASCII => b' ',
2096/// # _ => 0x40,
2097/// # }
2098/// # }
2099///
2100/// assert_eq!(zoned_space_byte(Codepage::ASCII), b' ');
2101/// assert_eq!(zoned_space_byte(Codepage::CP037), 0x40);
2102/// ```
2103#[inline]
2104const fn zoned_space_byte(codepage: Codepage) -> u8 {
2105 match codepage {
2106 Codepage::ASCII => b' ',
2107 _ => 0x40,
2108 }
2109}
2110
2111/// Get the expected zone nibble for valid digits
2112///
2113/// Returns the zone nibble value expected for digit bytes in zoned decimal
2114/// encoding for the given codepage.
2115///
2116/// # Arguments
2117/// * `codepage` - The target codepage
2118///
2119/// # Returns
2120/// * `0x3` for ASCII (digits 0x30-0x39)
2121/// * `0xF` for EBCDIC (digits 0xF0-0xF9)
2122///
2123/// # Examples
2124/// ```
2125/// use copybook_codec::options::Codepage;
2126/// # const ASCII_DIGIT_ZONE: u8 = 0x3;
2127/// # const EBCDIC_DIGIT_ZONE: u8 = 0xF;
2128/// # fn zoned_expected_zone(codepage: Codepage) -> u8 {
2129/// # match codepage {
2130/// # Codepage::ASCII => ASCII_DIGIT_ZONE,
2131/// # _ => EBCDIC_DIGIT_ZONE,
2132/// # }
2133/// # }
2134///
2135/// assert_eq!(zoned_expected_zone(Codepage::ASCII), 0x3);
2136/// assert_eq!(zoned_expected_zone(Codepage::CP037), 0xF);
2137/// ```
2138#[inline]
2139const fn zoned_expected_zone(codepage: Codepage) -> u8 {
2140 match codepage {
2141 Codepage::ASCII => ASCII_DIGIT_ZONE,
2142 _ => EBCDIC_DIGIT_ZONE,
2143 }
2144}
2145
2146/// Get a human-readable label for the encoding zone type
2147///
2148/// Returns a string label describing the encoding zone type for error messages.
2149///
2150/// # Arguments
2151/// * `codepage` - The target codepage
2152///
2153/// # Returns
2154/// * `"ASCII"` for ASCII codepage
2155/// * `"EBCDIC"` for EBCDIC codepages
2156///
2157/// # Examples
2158/// ```
2159/// use copybook_codec::options::Codepage;
2160/// # fn zoned_zone_label(codepage: Codepage) -> &'static str {
2161/// # match codepage {
2162/// # Codepage::ASCII => "ASCII",
2163/// # _ => "EBCDIC",
2164/// # }
2165/// # }
2166///
2167/// assert_eq!(zoned_zone_label(Codepage::ASCII), "ASCII");
2168/// assert_eq!(zoned_zone_label(Codepage::CP037), "EBCDIC");
2169/// ```
2170#[inline]
2171const fn zoned_zone_label(codepage: Codepage) -> &'static str {
2172 match codepage {
2173 Codepage::ASCII => "ASCII",
2174 _ => "EBCDIC",
2175 }
2176}
2177
2178/// Validate a non-final byte in a zoned decimal field
2179///
2180/// Checks that the byte contains a valid digit nibble (0-9) and the expected
2181/// zone nibble for the codepage. Non-final bytes should not contain sign information.
2182///
2183/// # Arguments
2184/// * `byte` - The byte to validate
2185/// * `index` - Position of the byte in the field (for error messages)
2186/// * `expected_zone` - Expected zone nibble value (0x3 for ASCII, 0xF for EBCDIC)
2187/// * `codepage` - Target codepage for zone validation
2188///
2189/// # Returns
2190/// The digit nibble value (0-9) extracted from the byte
2191///
2192/// # Errors
2193/// * `CBKD411_ZONED_BAD_SIGN` - Invalid digit nibble or mismatched zone
2194///
2195/// # Examples
2196/// ```text
2197/// // ASCII '5' is 0x35 (zone 0x3, digit 0x5)
2198/// let digit = zoned_validate_non_final_byte(0x35, 0, 0x3, Codepage::ASCII)?;
2199/// assert_eq!(digit, 5);
2200/// ```
2201#[inline]
2202fn zoned_validate_non_final_byte(
2203 byte: u8,
2204 index: usize,
2205 expected_zone: u8,
2206 codepage: Codepage,
2207) -> Result<u8> {
2208 let zone = (byte >> 4) & 0x0F;
2209 let digit = byte & 0x0F;
2210
2211 if digit > 9 {
2212 return Err(Error::new(
2213 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2214 format!("Invalid digit nibble 0x{digit:X} at position {index}"),
2215 ));
2216 }
2217
2218 if zone != expected_zone {
2219 let zone_label = zoned_zone_label(codepage);
2220 return Err(Error::new(
2221 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2222 format!(
2223 "Invalid {zone_label} zone 0x{zone:X} at position {index}, expected 0x{expected_zone:X}"
2224 ),
2225 ));
2226 }
2227
2228 Ok(digit)
2229}
2230
2231/// Process all non-final digits in a zoned decimal field
2232///
2233/// Validates each byte's zone and digit nibbles, accumulates the numeric value,
2234/// and stores digits in the scratch buffer for verification.
2235///
2236/// # Arguments
2237/// * `data` - Non-final bytes of the zoned decimal field
2238/// * `expected_zone` - Expected zone nibble (0x3 for ASCII, 0xF for EBCDIC)
2239/// * `codepage` - Target codepage
2240/// * `scratch` - Scratch buffers for digit accumulation
2241///
2242/// # Returns
2243/// Accumulated integer value from non-final digits
2244///
2245/// # Errors
2246/// * `CBKD410_ZONED_OVERFLOW` - Magnitude exceeds `i64` capacity
2247/// * `CBKD411_ZONED_BAD_SIGN` - Invalid zone or digit nibble encountered
2248#[inline]
2249fn zoned_process_non_final_digits(
2250 data: &[u8],
2251 expected_zone: u8,
2252 codepage: Codepage,
2253 scratch: &mut ScratchBuffers,
2254) -> Result<i64> {
2255 let mut value = 0i64;
2256
2257 for (index, &byte) in data.iter().enumerate() {
2258 let digit = zoned_validate_non_final_byte(byte, index, expected_zone, codepage)?;
2259 scratch.digit_buffer.push(digit);
2260 value = zoned_append_digit(value, digit, index)?;
2261 }
2262
2263 Ok(value)
2264}
2265
2266/// Decode the last byte of a zoned decimal field
2267///
2268/// The last byte contains both a digit and sign information encoded as an
2269/// overpunch character. Delegates to the overpunch decoder for extraction.
2270///
2271/// # Arguments
2272/// * `byte` - The final byte of the zoned decimal field
2273/// * `codepage` - Target codepage for overpunch interpretation
2274///
2275/// # Returns
2276/// Tuple of (digit, `is_negative`) extracted from the overpunch byte
2277///
2278/// # Errors
2279/// * `CBKD411_ZONED_BAD_SIGN` - Invalid overpunch encoding
2280///
2281/// # See Also
2282/// * `zoned_overpunch::decode_overpunch_byte` - Underlying overpunch decoder
2283#[inline]
2284fn zoned_decode_last_byte(byte: u8, codepage: Codepage) -> Result<(u8, bool)> {
2285 crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)
2286}
2287
2288/// Ensure unsigned zoned decimal has no sign information
2289///
2290/// Validates that an unsigned zoned decimal field contains only unsigned zone
2291/// nibbles and no negative overpunch encoding.
2292///
2293/// # Arguments
2294/// * `last_byte` - The final byte of the field
2295/// * `expected_zone` - Expected unsigned zone (0x3 for ASCII, 0xF for EBCDIC)
2296/// * `codepage` - Target codepage
2297/// * `negative` - Whether overpunch decoding detected a negative sign
2298///
2299/// # Returns
2300/// Always returns `Ok(false)` for valid unsigned fields
2301///
2302/// # Errors
2303/// * `CBKD411_ZONED_BAD_SIGN` - Sign zone or negative overpunch in unsigned field
2304///
2305/// # Examples
2306/// ```text
2307/// // Valid unsigned ASCII zoned decimal ends with zone 0x3
2308/// let result = zoned_ensure_unsigned(0x35, 0x3, Codepage::ASCII, false)?;
2309/// assert_eq!(result, false);
2310/// ```
2311#[inline]
2312fn zoned_ensure_unsigned(
2313 last_byte: u8,
2314 expected_zone: u8,
2315 codepage: Codepage,
2316 negative: bool,
2317) -> Result<bool> {
2318 let zone = (last_byte >> 4) & 0x0F;
2319 if zone != expected_zone {
2320 let zone_label = zoned_zone_label(codepage);
2321 return Err(Error::new(
2322 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2323 format!(
2324 "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
2325 ),
2326 ));
2327 }
2328
2329 if negative {
2330 return Err(Error::new(
2331 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2332 "Unsigned zoned decimal contains negative overpunch",
2333 ));
2334 }
2335
2336 Ok(false)
2337}
2338
2339/// Decode a zoned decimal using the configured code page and policy while reusing scratch buffers.
2340///
2341/// Decodes zoned decimal fields while reusing scratch buffers to avoid repeated allocations.
2342/// This is optimized for high-throughput processing where the same scratch buffers
2343/// are used across multiple decode operations.
2344///
2345/// # Arguments
2346/// * `data` - Raw byte data containing the zoned decimal
2347/// * `digits` - Number of digit characters (field length)
2348/// * `scale` - Number of decimal places (can be negative for scaling)
2349/// * `signed` - Whether the field is signed (true) or unsigned (false)
2350/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
2351/// * `blank_when_zero` - If true, all-space fields decode as zero
2352/// * `scratch` - Mutable reference to scratch buffers for reuse
2353///
2354/// # Returns
2355/// A `SmallDecimal` containing of decoded value
2356///
2357/// # Policy
2358/// Defaults to *preferred zero sign* (`ZeroSignPolicy::Preferred`) for EBCDIC zeros unless
2359/// `preserve_zoned_encoding` captured an explicit format at decode.
2360///
2361/// # Errors
2362/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
2363/// * `CBKD411_ZONED_BAD_SIGN` - if the zone nibbles or last-byte sign are invalid
2364///
2365/// # Performance
2366/// This function avoids allocations by reusing scratch buffers across decode operations.
2367/// Use this for processing multiple zoned decimal fields in a loop.
2368///
2369/// # Examples
2370///
2371/// ## Basic Decoding
2372///
2373/// ```no_run
2374/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
2375/// use copybook_codec::runtime::ScratchBuffers;
2376/// use copybook_codec::options::Codepage;
2377///
2378/// let mut scratch = ScratchBuffers::new();
2379/// let data = b"123";
2380/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, false, &mut scratch)?;
2381/// assert_eq!(result.to_string(), "123");
2382/// # Ok::<(), copybook_core::Error>(())
2383/// ```
2384///
2385/// ## With BLANK WHEN ZERO
2386///
2387/// ```no_run
2388/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
2389/// use copybook_codec::runtime::ScratchBuffers;
2390/// use copybook_codec::options::Codepage;
2391///
2392/// let mut scratch = ScratchBuffers::new();
2393/// let data = b" "; // 3 ASCII spaces
2394/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, true, &mut scratch)?;
2395/// assert_eq!(result.to_string(), "0");
2396/// # Ok::<(), copybook_core::Error>(())
2397/// ```
2398///
2399/// # See Also
2400/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
2401/// * [`crate::runtime::ScratchBuffers`] - For scratch buffer management
2402#[inline]
2403#[must_use = "Handle the Result or propagate the error"]
2404pub fn decode_zoned_decimal_with_scratch(
2405 data: &[u8],
2406 digits: u16,
2407 scale: i16,
2408 signed: bool,
2409 codepage: Codepage,
2410 blank_when_zero: bool,
2411 scratch: &mut ScratchBuffers,
2412) -> Result<SmallDecimal> {
2413 if data.len() != usize::from(digits) {
2414 return Err(Error::new(
2415 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2416 format!(
2417 "Zoned decimal data length {} doesn't match digits {}",
2418 data.len(),
2419 digits
2420 ),
2421 ));
2422 }
2423
2424 // Check for BLANK WHEN ZERO (all spaces) - optimized check
2425 let space_byte = zoned_space_byte(codepage);
2426
2427 let is_all_spaces = data.iter().all(|&b| b == space_byte);
2428 if is_all_spaces {
2429 if blank_when_zero {
2430 warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
2431 crate::lib_api::increment_warning_counter();
2432 return Ok(SmallDecimal::zero(scale));
2433 }
2434 return Err(Error::new(
2435 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2436 "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
2437 ));
2438 }
2439
2440 // Clear and prepare digit buffer for reuse
2441 scratch.digit_buffer.clear();
2442 scratch.digit_buffer.reserve(usize::from(digits));
2443
2444 let expected_zone = zoned_expected_zone(codepage);
2445 let Some((&last_byte, non_final)) = data.split_last() else {
2446 return Err(Error::new(
2447 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2448 "Zoned decimal field is empty",
2449 ));
2450 };
2451 let partial_value =
2452 zoned_process_non_final_digits(non_final, expected_zone, codepage, scratch)?;
2453 let (last_digit, negative) = zoned_decode_last_byte(last_byte, codepage)?;
2454 scratch.digit_buffer.push(last_digit);
2455 let is_negative = if signed {
2456 negative
2457 } else {
2458 zoned_ensure_unsigned(last_byte, expected_zone, codepage, negative)?
2459 };
2460 let value = zoned_append_digit(partial_value, last_digit, non_final.len())?;
2461 let mut decimal = SmallDecimal::new(value, scale, is_negative);
2462 decimal.normalize();
2463
2464 debug_assert!(
2465 scratch.digit_buffer.iter().all(|&d| d <= 9),
2466 "scratch digit buffer must contain only logical digits"
2467 );
2468 Ok(decimal)
2469}
2470
2471/// Decode a single-byte packed decimal value
2472///
2473/// Handles the special case where the entire packed decimal fits in one byte.
2474/// For 1-digit fields, the high nibble contains the digit and low nibble contains
2475/// the sign. For 0-digit fields (just sign), only the low nibble is significant.
2476///
2477/// # Arguments
2478/// * `byte` - The packed decimal byte
2479/// * `digits` - Number of digits (0 or 1 for single byte)
2480/// * `scale` - Decimal scale
2481/// * `signed` - Whether the field is signed
2482///
2483/// # Returns
2484/// Decoded `SmallDecimal` value
2485///
2486/// # Errors
2487/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
2488///
2489/// # Format
2490/// Single-byte packed decimals:
2491/// - 1 digit: `[digit][sign]` (e.g., 0x5C = 5 positive)
2492/// - 0 digits: `[0][sign]` (just sign, high nibble must be 0)
2493///
2494/// Valid sign nibbles:
2495/// - Positive: 0xA, 0xC, 0xE, 0xF
2496/// - Negative: 0xB, 0xD
2497/// - Unsigned: 0xF only
2498#[inline]
2499fn packed_decode_single_byte(
2500 byte: u8,
2501 digits: u16,
2502 scale: i16,
2503 signed: bool,
2504) -> Result<SmallDecimal> {
2505 let high_nibble = (byte >> 4) & 0x0F;
2506 let low_nibble = byte & 0x0F;
2507 let mut value = 0i64;
2508
2509 if digits == 1 {
2510 if high_nibble > 9 {
2511 return Err(Error::new(
2512 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2513 format!("Invalid digit nibble 0x{high_nibble:X}"),
2514 ));
2515 }
2516 value = i64::from(high_nibble);
2517 }
2518
2519 let is_negative = if signed {
2520 match low_nibble {
2521 0xA | 0xC | 0xE | 0xF => false,
2522 0xB | 0xD => true,
2523 _ => {
2524 return Err(Error::new(
2525 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2526 format!("Invalid sign nibble 0x{low_nibble:X}"),
2527 ));
2528 }
2529 }
2530 } else {
2531 if low_nibble != 0xF {
2532 return Err(Error::new(
2533 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2534 format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
2535 ));
2536 }
2537 false
2538 };
2539
2540 Ok(create_normalized_decimal(value, scale, is_negative))
2541}
2542
2543/// Add a digit to the accumulating packed decimal value
2544///
2545/// Multiplies the current value by 10 and adds the new digit, with overflow checking.
2546///
2547/// # Arguments
2548/// * `value` - Mutable reference to the accumulating value
2549/// * `digit` - Digit to add (0-9)
2550///
2551/// # Returns
2552/// `Ok(())` on success
2553///
2554/// # Errors
2555/// * `CBKD411_ZONED_BAD_SIGN` - Numeric overflow during accumulation
2556///
2557/// # Performance
2558/// Uses checked arithmetic to prevent panics while detecting overflow conditions.
2559#[inline]
2560fn packed_push_digit(value: &mut i64, digit: u8) -> Result<()> {
2561 *value = value
2562 .checked_mul(10)
2563 .and_then(|v| v.checked_add(i64::from(digit)))
2564 .ok_or_else(|| {
2565 Error::new(
2566 ErrorCode::CBKD411_ZONED_BAD_SIGN,
2567 "Numeric overflow during zoned decimal conversion",
2568 )
2569 })?;
2570 Ok(())
2571}
2572
2573/// Process non-final bytes of a multi-byte packed decimal
2574///
2575/// Extracts digit nibbles from all bytes before the last one, handling padding
2576/// if the digit count is odd. Accumulates the numeric value and counts digits.
2577///
2578/// # Arguments
2579/// * `bytes` - Non-final bytes of the packed decimal
2580/// * `digits` - Total number of digits in the field
2581/// * `has_padding` - Whether the first nibble is padding (odd total nibbles)
2582///
2583/// # Returns
2584/// Tuple of (`accumulated_value`, `digit_count`)
2585///
2586/// # Errors
2587/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or padding nibble
2588///
2589/// # Format
2590/// Packed decimal nibble layout:
2591/// - Even digits: `[pad=0][d1][d2][d3]...[sign]`
2592/// - Odd digits: `[d1][d2][d3]...[sign]` (no padding)
2593#[inline]
2594fn packed_process_non_last_bytes(
2595 bytes: &[u8],
2596 digits: u16,
2597 has_padding: bool,
2598) -> Result<(i64, u16)> {
2599 let mut value = 0i64;
2600 let mut digit_count: u16 = 0;
2601
2602 for (index, &byte) in bytes.iter().enumerate() {
2603 let high_nibble = (byte >> 4) & 0x0F;
2604 let low_nibble = byte & 0x0F;
2605
2606 if index == 0 && has_padding {
2607 if high_nibble != 0 {
2608 return Err(Error::new(
2609 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2610 format!("Expected padding nibble 0, got 0x{high_nibble:X}"),
2611 ));
2612 }
2613 } else {
2614 if high_nibble > 9 {
2615 return Err(Error::new(
2616 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2617 format!("Invalid digit nibble 0x{high_nibble:X}"),
2618 ));
2619 }
2620 packed_push_digit(&mut value, high_nibble)?;
2621 digit_count += 1;
2622 }
2623
2624 if digit_count >= digits {
2625 break;
2626 }
2627
2628 if low_nibble > 9 {
2629 return Err(Error::new(
2630 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2631 format!("Invalid digit nibble 0x{low_nibble:X}"),
2632 ));
2633 }
2634 packed_push_digit(&mut value, low_nibble)?;
2635 digit_count += 1;
2636
2637 if digit_count >= digits {
2638 break;
2639 }
2640 }
2641
2642 Ok((value, digit_count))
2643}
2644
2645/// Process the last byte of a packed decimal field
2646///
2647/// Extracts the final digit (if needed) and sign nibble from the last byte.
2648/// Creates the final normalized `SmallDecimal` value.
2649///
2650/// # Arguments
2651/// * `value` - Accumulated value from previous bytes
2652/// * `last_byte` - The final byte containing digit and sign
2653/// * `digits` - Total number of digits expected
2654/// * `digit_count` - Number of digits already processed
2655/// * `scale` - Decimal scale
2656/// * `signed` - Whether the field is signed
2657///
2658/// # Returns
2659/// Decoded and normalized `SmallDecimal`
2660///
2661/// # Errors
2662/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
2663///
2664/// # Format
2665/// Last byte always ends with sign nibble:
2666/// - If `digit_count` < digits: `[digit][sign]`
2667/// - If `digit_count` == digits: `[unused][sign]` (high nibble ignored)
2668#[inline]
2669fn packed_finish_last_byte(
2670 mut value: i64,
2671 last_byte: u8,
2672 digits: u16,
2673 digit_count: u16,
2674 scale: i16,
2675 signed: bool,
2676) -> Result<SmallDecimal> {
2677 let high_nibble = (last_byte >> 4) & 0x0F;
2678 let low_nibble = last_byte & 0x0F;
2679
2680 if digit_count < digits {
2681 if high_nibble > 9 {
2682 return Err(Error::new(
2683 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2684 format!("Invalid digit nibble 0x{high_nibble:X}"),
2685 ));
2686 }
2687 packed_push_digit(&mut value, high_nibble)?;
2688 }
2689
2690 let is_negative = if signed {
2691 match low_nibble {
2692 0xA | 0xC | 0xE | 0xF => false,
2693 0xB | 0xD => true,
2694 _ => {
2695 return Err(Error::new(
2696 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2697 format!("Invalid sign nibble 0x{low_nibble:X}"),
2698 ));
2699 }
2700 }
2701 } else {
2702 if low_nibble != 0xF {
2703 return Err(Error::new(
2704 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2705 format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
2706 ));
2707 }
2708 false
2709 };
2710
2711 Ok(create_normalized_decimal(value, scale, is_negative))
2712}
2713
2714/// Decode a multi-byte packed decimal value
2715///
2716/// Orchestrates the decoding of packed decimals that span multiple bytes by
2717/// processing non-final bytes and the final byte separately.
2718///
2719/// # Arguments
2720/// * `data` - Complete packed decimal byte array
2721/// * `digits` - Number of digits in the field
2722/// * `scale` - Decimal scale
2723/// * `signed` - Whether the field is signed
2724///
2725/// # Returns
2726/// Decoded `SmallDecimal` value
2727///
2728/// # Errors
2729/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid nibbles or empty input
2730///
2731/// # Algorithm
2732/// 1. Calculate if padding nibble is present (odd total nibbles)
2733/// 2. Process all non-final bytes to extract digits
2734/// 3. Process final byte to extract last digit and sign
2735/// 4. Construct normalized `SmallDecimal`
2736#[inline]
2737fn packed_decode_multi_byte(
2738 data: &[u8],
2739 digits: u16,
2740 scale: i16,
2741 signed: bool,
2742) -> Result<SmallDecimal> {
2743 let total_nibbles = digits + 1;
2744 let has_padding = (total_nibbles & 1) == 1;
2745 let Some((&last_byte, non_last)) = data.split_last() else {
2746 return Err(Error::new(
2747 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2748 "Packed decimal input is empty",
2749 ));
2750 };
2751 let (value, digit_count) = packed_process_non_last_bytes(non_last, digits, has_padding)?;
2752 packed_finish_last_byte(value, last_byte, digits, digit_count, scale, signed)
2753}
2754
2755/// Optimized packed decimal decoder using scratch buffers
2756/// Minimizes allocations by reusing digit buffer
2757///
2758/// # Errors
2759/// Returns an error when the packed decimal data has an invalid length or contains bad digit/sign nibbles.
2760#[inline]
2761#[must_use = "Handle the Result or propagate the error"]
2762pub fn decode_packed_decimal_with_scratch(
2763 data: &[u8],
2764 digits: u16,
2765 scale: i16,
2766 signed: bool,
2767 scratch: &mut ScratchBuffers,
2768) -> Result<SmallDecimal> {
2769 let expected_bytes = usize::from((digits + 1).div_ceil(2));
2770 if data.len() != expected_bytes {
2771 return Err(Error::new(
2772 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2773 format!(
2774 "Packed decimal data length {} doesn't match expected {} bytes for {} digits",
2775 data.len(),
2776 expected_bytes,
2777 digits
2778 ),
2779 ));
2780 }
2781
2782 if data.is_empty() {
2783 return Ok(SmallDecimal::zero(scale));
2784 }
2785
2786 // Use the original implementation - the "optimized" path actually hurts performance
2787 // Clear and prepare digit buffer for reuse
2788 scratch.digit_buffer.clear();
2789 scratch.digit_buffer.reserve(usize::from(digits));
2790
2791 // Optimized nibble processing - unify handling for multi-byte cases
2792 let decimal = if data.len() == 1 {
2793 packed_decode_single_byte(data[0], digits, scale, signed)?
2794 } else {
2795 packed_decode_multi_byte(data, digits, scale, signed)?
2796 };
2797
2798 debug_assert!(
2799 scratch.digit_buffer.iter().all(|&d| d <= 9),
2800 "scratch digit buffer must contain only logical digits"
2801 );
2802
2803 Ok(decimal)
2804}
2805
2806/// Encode a zoned decimal while reusing caller-owned scratch buffers to avoid
2807/// per-call heap allocations on the hot path.
2808///
2809/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
2810/// the scratch buffer and then delegates to [`encode_zoned_decimal`]. The
2811/// `_bwz_encode` parameter is reserved for future BLANK WHEN ZERO integration
2812/// but is currently unused.
2813///
2814/// # Arguments
2815/// * `decimal` - Pre-parsed decimal value to encode
2816/// * `digits` - Number of digit positions in the COBOL field
2817/// * `signed` - Whether the field carries a sign
2818/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
2819/// * `_bwz_encode` - Reserved for BLANK WHEN ZERO support (currently unused)
2820/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
2821///
2822/// # Returns
2823/// A vector of bytes containing the encoded zoned decimal.
2824///
2825/// # Policy
2826/// Callers typically resolve policy using `zoned_encoding_override` → preserved
2827/// metadata → `preferred_zoned_encoding`, matching the documented library
2828/// behavior for zoned decimals.
2829///
2830/// # Errors
2831/// Returns an error when the decimal value cannot be represented with the
2832/// requested digit count or encoding format.
2833///
2834/// # See Also
2835/// * [`encode_zoned_decimal`] - Underlying encoder
2836/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
2837#[inline]
2838#[must_use = "Handle the Result or propagate the error"]
2839pub fn encode_zoned_decimal_with_scratch(
2840 decimal: &SmallDecimal,
2841 digits: u16,
2842 signed: bool,
2843 codepage: Codepage,
2844 _bwz_encode: bool,
2845 scratch: &mut ScratchBuffers,
2846) -> Result<Vec<u8>> {
2847 // Clear and prepare buffers
2848 scratch.digit_buffer.clear();
2849 scratch.byte_buffer.clear();
2850 scratch.byte_buffer.reserve(usize::from(digits));
2851
2852 // Convert decimal to string using scratch buffer
2853 scratch.string_buffer.clear();
2854 scratch.string_buffer.push_str(&decimal.to_string());
2855
2856 // Use the standard encode function but with optimized digit processing
2857 // This is a placeholder for now - the actual optimization would involve
2858 // rewriting the encode logic to use the scratch buffers
2859 encode_zoned_decimal(
2860 &scratch.string_buffer,
2861 digits,
2862 decimal.scale,
2863 signed,
2864 codepage,
2865 )
2866}
2867
2868/// Encode a packed decimal (COMP-3) while reusing caller-owned scratch buffers
2869/// to minimize per-call allocations.
2870///
2871/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
2872/// the scratch buffer and then delegates to [`encode_packed_decimal`]. Intended
2873/// for use on codec hot paths where many records are encoded sequentially with
2874/// the same [`ScratchBuffers`] instance.
2875///
2876/// # Arguments
2877/// * `decimal` - Pre-parsed decimal value to encode
2878/// * `digits` - Number of decimal digits in the field (1-18)
2879/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
2880/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
2881///
2882/// # Returns
2883/// A vector of bytes containing the encoded packed decimal (COMP-3 format).
2884///
2885/// # Errors
2886/// Returns an error when the decimal value cannot be encoded into the
2887/// requested packed representation (delegates to [`encode_packed_decimal`]).
2888///
2889/// # See Also
2890/// * [`encode_packed_decimal`] - Underlying packed decimal encoder
2891/// * [`encode_zoned_decimal_with_scratch`] - Scratch-based zoned decimal encoder
2892/// * [`decode_packed_decimal_to_string_with_scratch`] - Scratch-based decoder
2893#[inline]
2894#[must_use = "Handle the Result or propagate the error"]
2895pub fn encode_packed_decimal_with_scratch(
2896 decimal: &SmallDecimal,
2897 digits: u16,
2898 signed: bool,
2899 scratch: &mut ScratchBuffers,
2900) -> Result<Vec<u8>> {
2901 // Clear and prepare buffers
2902 scratch.digit_buffer.clear();
2903 scratch.byte_buffer.clear();
2904 let expected_bytes = usize::from((digits + 1).div_ceil(2));
2905 scratch.byte_buffer.reserve(expected_bytes);
2906
2907 // Convert decimal to string using scratch buffer
2908 scratch.string_buffer.clear();
2909 scratch.string_buffer.push_str(&decimal.to_string());
2910
2911 // Use the standard encode function but with optimized nibble processing
2912 // This is a placeholder for now - the actual optimization would involve
2913 // rewriting the encode logic to use the scratch buffers
2914 encode_packed_decimal(&scratch.string_buffer, digits, decimal.scale, signed)
2915}
2916
2917/// Decode a packed decimal (COMP-3) directly to a `String`, bypassing the
2918/// intermediate [`SmallDecimal`] allocation.
2919///
2920/// This is a critical performance optimization for COMP-3 JSON conversion.
2921/// By decoding nibbles and formatting the result in a single pass using the
2922/// caller-owned scratch buffer, it avoids the `SmallDecimal` -> `String`
2923/// allocation overhead that caused 94-96% throughput regression in COMP-3
2924/// processing benchmarks.
2925///
2926/// # Arguments
2927/// * `data` - Raw byte data containing the packed decimal (BCD with trailing sign nibble)
2928/// * `digits` - Number of decimal digits in the field (1-18)
2929/// * `scale` - Number of implied decimal places (can be negative for scaling)
2930/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
2931/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
2932/// `std::mem::take` and returned as the result string.
2933///
2934/// # Returns
2935/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
2936///
2937/// # Errors
2938/// * `CBKD401_COMP3_INVALID_NIBBLE` - if any data nibble is > 9 or the sign
2939/// nibble is invalid
2940///
2941/// # Performance
2942/// Includes a fast path for single-digit packed decimals (1 byte) and falls
2943/// back to [`decode_packed_decimal_with_scratch`] plus
2944/// [`SmallDecimal::format_to_scratch_buffer`] for larger values.
2945///
2946/// # See Also
2947/// * [`decode_packed_decimal`] - Returns a `SmallDecimal` instead of a string
2948/// * [`decode_packed_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
2949/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
2950#[inline]
2951#[must_use = "Handle the Result or propagate the error"]
2952pub fn decode_packed_decimal_to_string_with_scratch(
2953 data: &[u8],
2954 digits: u16,
2955 scale: i16,
2956 signed: bool,
2957 scratch: &mut ScratchBuffers,
2958) -> Result<String> {
2959 // SIMD-friendly sign lookup table for faster branch-free sign detection
2960 // Index by nibble value: 0=invalid, 1=positive, 2=negative
2961 const SIGN_TABLE: [u8; 16] = [
2962 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x0-0x9: invalid
2963 1, 2, 1, 2, 1, 1, // 0xA=pos, 0xB=neg, 0xC=pos, 0xD=neg, 0xE=pos, 0xF=pos
2964 ];
2965
2966 // CRITICAL OPTIMIZATION: Direct decode-to-string path to avoid SmallDecimal allocation
2967 if data.is_empty() {
2968 return Ok("0".to_string());
2969 }
2970
2971 // Fast path for common single-digit packed decimals
2972 if data.len() == 1 && digits == 1 {
2973 let byte = data[0];
2974 let high_nibble = (byte >> 4) & 0x0F;
2975 let low_nibble = byte & 0x0F;
2976
2977 let mut is_negative = false;
2978
2979 // Single digit: high nibble is unused (should be 0), low nibble is sign
2980 if high_nibble > 9 {
2981 return Err(Error::new(
2982 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2983 format!("Invalid digit nibble 0x{high_nibble:X}"),
2984 ));
2985 }
2986 let value = i64::from(high_nibble);
2987
2988 if signed {
2989 // SIMD-friendly branch-free sign detection
2990 let sign_code = SIGN_TABLE[usize::from(low_nibble)];
2991 if sign_code == 0 {
2992 return Err(Error::new(
2993 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2994 format!("Invalid sign nibble 0x{low_nibble:X}"),
2995 ));
2996 }
2997 is_negative = sign_code == 2;
2998 } else if low_nibble != 0xF {
2999 return Err(Error::new(
3000 ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3001 format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
3002 ));
3003 }
3004
3005 // Format directly to string without SmallDecimal
3006 scratch.string_buffer.clear();
3007 if is_negative && value != 0 {
3008 scratch.string_buffer.push('-');
3009 }
3010
3011 if scale <= 0 {
3012 // Integer format
3013 let scaled_value = if scale < 0 {
3014 value * 10_i64.pow(scale_abs_to_u32(scale))
3015 } else {
3016 value
3017 };
3018 format_integer_to_buffer(scaled_value, &mut scratch.string_buffer);
3019 } else {
3020 // Decimal format
3021 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
3022 let integer_part = value / divisor;
3023 let fractional_part = value % divisor;
3024
3025 format_integer_to_buffer(integer_part, &mut scratch.string_buffer);
3026 scratch.string_buffer.push('.');
3027 format_integer_with_leading_zeros_to_buffer(
3028 fractional_part,
3029 scale_abs_to_u32(scale),
3030 &mut scratch.string_buffer,
3031 );
3032 }
3033
3034 // CRITICAL OPTIMIZATION: Move string content without cloning
3035 let result = std::mem::take(&mut scratch.string_buffer);
3036 return Ok(result);
3037 }
3038
3039 // Fall back to general case for larger packed decimals
3040 let decimal = decode_packed_decimal_with_scratch(data, digits, scale, signed, scratch)?;
3041
3042 // Now format to string using the optimized scratch buffer method
3043 decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
3044
3045 // CRITICAL OPTIMIZATION: Move string content without cloning
3046 let result = std::mem::take(&mut scratch.string_buffer);
3047 Ok(result)
3048}
3049
3050/// Format a binary integer into the caller-owned scratch buffer.
3051///
3052/// ## Why scratch?
3053/// Avoids hot-path allocations in codec routes that emit integers frequently
3054/// (zoned/packed/binary). This writes into `scratch` and returns that buffer,
3055/// so callers must reuse the same `ScratchBuffers` instance across a walk.
3056///
3057/// ## Contract
3058/// - No allocations on the hot path
3059/// - Returns the scratch-backed `String` (valid until next reuse/clear)
3060#[inline]
3061#[must_use = "Use the formatted string or continue mutating the scratch buffer"]
3062pub fn format_binary_int_to_string_with_scratch(
3063 value: i64,
3064 scratch: &mut ScratchBuffers,
3065) -> String {
3066 scratch.string_buffer.clear();
3067
3068 if value < 0 {
3069 scratch.string_buffer.push('-');
3070 if value == i64::MIN {
3071 // Avoid overflow when negating i64::MIN
3072 scratch.string_buffer.push_str("9223372036854775808");
3073 return std::mem::take(&mut scratch.string_buffer);
3074 }
3075 format_integer_to_buffer(-value, &mut scratch.string_buffer);
3076 } else {
3077 format_integer_to_buffer(value, &mut scratch.string_buffer);
3078 }
3079
3080 std::mem::take(&mut scratch.string_buffer)
3081}
3082
3083/// Format an integer to a string buffer with optimized performance
3084///
3085/// Provides ultra-fast integer-to-string conversion optimized for COBOL numeric
3086/// decoding hot paths. Uses manual digit extraction to avoid format macro overhead.
3087///
3088/// # Arguments
3089/// * `value` - Integer value to format
3090/// * `buffer` - String buffer to append digits to
3091///
3092/// # Performance
3093/// Critical optimization for COMP-3 and zoned decimal JSON conversion. Avoids
3094/// the overhead of Rust's standard formatting macros through manual digit extraction.
3095///
3096/// # Examples
3097/// ```text
3098/// let mut buffer = String::new();
3099/// format_integer_to_buffer(12345, &mut buffer);
3100/// assert_eq!(buffer, "12345");
3101/// ```
3102#[inline]
3103fn format_integer_to_buffer(value: i64, buffer: &mut String) {
3104 SmallDecimal::format_integer_manual(value, buffer);
3105}
3106
3107/// Format an integer with leading zeros to a string buffer
3108///
3109/// Formats an integer with exactly `width` digits, padding with leading zeros
3110/// if necessary. Optimized for decimal formatting where fractional parts must
3111/// maintain precise digit counts.
3112///
3113/// # Arguments
3114/// * `value` - Integer value to format
3115/// * `width` - Number of digits in output (with leading zeros)
3116/// * `buffer` - String buffer to append formatted digits to
3117///
3118/// # Performance
3119/// Optimized for common COBOL scales (0-4 decimal places) with specialized
3120/// fast paths. Critical for maintaining COMP-3 decimal precision.
3121///
3122/// # Examples
3123/// ```text
3124/// let mut buffer = String::new();
3125/// format_integer_with_leading_zeros_to_buffer(45, 4, &mut buffer);
3126/// assert_eq!(buffer, "0045");
3127/// ```
3128#[inline]
3129fn format_integer_with_leading_zeros_to_buffer(value: i64, width: u32, buffer: &mut String) {
3130 SmallDecimal::format_integer_with_leading_zeros(value, width, buffer);
3131}
3132
3133/// Decode a zoned decimal directly to a `String`, bypassing the intermediate
3134/// [`SmallDecimal`] allocation.
3135///
3136/// Analogous to [`decode_packed_decimal_to_string_with_scratch`] but for zoned
3137/// decimal (PIC 9 / PIC S9) fields. Decodes via
3138/// [`decode_zoned_decimal_with_scratch`] and then formats the result into the
3139/// scratch string buffer, avoiding a separate heap allocation.
3140///
3141/// # Arguments
3142/// * `data` - Raw byte data containing the zoned decimal
3143/// * `digits` - Number of digit characters (field length)
3144/// * `scale` - Number of implied decimal places (can be negative for scaling)
3145/// * `signed` - Whether the field carries a sign (overpunch in last byte)
3146/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
3147/// * `blank_when_zero` - If `true`, all-space fields decode as `"0"`
3148/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
3149/// `std::mem::take` and returned as the result string.
3150///
3151/// # Returns
3152/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
3153///
3154/// # Policy
3155/// Mirrors [`decode_zoned_decimal_with_scratch`], inheriting its default
3156/// preferred-zero handling for EBCDIC data.
3157///
3158/// # Errors
3159/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
3160/// * `CBKD411_ZONED_BAD_SIGN` - if the zone nibbles or sign are invalid
3161///
3162/// # See Also
3163/// * [`decode_zoned_decimal`] - Returns a `SmallDecimal` instead of a string
3164/// * [`decode_zoned_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
3165/// * [`decode_packed_decimal_to_string_with_scratch`] - Equivalent for packed decimals
3166#[inline]
3167#[must_use = "Handle the Result or propagate the error"]
3168pub fn decode_zoned_decimal_to_string_with_scratch(
3169 data: &[u8],
3170 digits: u16,
3171 scale: i16,
3172 signed: bool,
3173 codepage: Codepage,
3174 blank_when_zero: bool,
3175 scratch: &mut ScratchBuffers,
3176) -> Result<String> {
3177 // First decode to SmallDecimal using existing optimized decoder
3178 let decimal = decode_zoned_decimal_with_scratch(
3179 data,
3180 digits,
3181 scale,
3182 signed,
3183 codepage,
3184 blank_when_zero,
3185 scratch,
3186 )?;
3187
3188 // Special-case integer zoned decimals for digit padding consistency
3189 if scale == 0 && !blank_when_zero {
3190 if decimal.value == 0 {
3191 scratch.string_buffer.clear();
3192 scratch.string_buffer.push('0');
3193 } else {
3194 scratch.string_buffer.clear();
3195 if decimal.negative && decimal.value != 0 {
3196 scratch.string_buffer.push('-');
3197 }
3198
3199 let magnitude = if decimal.scale < 0 {
3200 decimal.value * 10_i64.pow(scale_abs_to_u32(decimal.scale))
3201 } else {
3202 decimal.value
3203 };
3204
3205 SmallDecimal::format_integer_with_leading_zeros(
3206 magnitude,
3207 u32::from(digits),
3208 &mut scratch.string_buffer,
3209 );
3210 }
3211
3212 return Ok(std::mem::take(&mut scratch.string_buffer));
3213 }
3214
3215 // Fallback to general fixed-scale formatting using scratch buffer
3216 decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
3217 Ok(std::mem::take(&mut scratch.string_buffer))
3218}
3219
3220// =============================================================================
3221// Floating-Point Codecs (COMP-1 / COMP-2)
3222// =============================================================================
3223
3224#[cfg(test)]
3225#[allow(clippy::expect_used)]
3226#[allow(clippy::unwrap_used)]
3227#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3228mod tests {
3229 use super::*;
3230 use crate::zoned_overpunch::{ZeroSignPolicy, encode_overpunch_byte, is_valid_overpunch};
3231 use proptest::prelude::*;
3232 use proptest::test_runner::RngSeed;
3233 use std::collections::hash_map::DefaultHasher;
3234 use std::hash::{Hash, Hasher};
3235
3236 fn proptest_case_count() -> u32 {
3237 option_env!("PROPTEST_CASES")
3238 .and_then(|s| s.parse().ok())
3239 .unwrap_or(256)
3240 }
3241
3242 fn numeric_proptest_config() -> ProptestConfig {
3243 let mut cfg = ProptestConfig {
3244 cases: proptest_case_count(),
3245 max_shrink_time: 0,
3246 ..ProptestConfig::default()
3247 };
3248
3249 if let Ok(seed_value) = std::env::var("PROPTEST_SEED")
3250 && !seed_value.is_empty()
3251 {
3252 let parsed_seed = seed_value.parse::<u64>().unwrap_or_else(|_| {
3253 let mut hasher = DefaultHasher::new();
3254 seed_value.hash(&mut hasher);
3255 hasher.finish()
3256 });
3257 cfg.rng_seed = RngSeed::Fixed(parsed_seed);
3258 }
3259
3260 cfg
3261 }
3262
3263 #[test]
3264 fn test_small_decimal_normalization() {
3265 let mut decimal = SmallDecimal::new(0, 2, true);
3266 decimal.normalize();
3267 assert!(!decimal.negative); // -0 should become 0
3268 }
3269
3270 #[test]
3271 fn test_small_decimal_formatting() {
3272 // Integer format (scale=0)
3273 let decimal = SmallDecimal::new(123, 0, false);
3274 assert_eq!(decimal.to_string(), "123");
3275
3276 // Decimal format with fixed scale
3277 let decimal = SmallDecimal::new(12345, 2, false);
3278 assert_eq!(decimal.to_string(), "123.45");
3279
3280 // Negative decimal
3281 let decimal = SmallDecimal::new(12345, 2, true);
3282 assert_eq!(decimal.to_string(), "-123.45");
3283 }
3284
3285 #[test]
3286 fn test_zero_with_scale_preserves_decimal_places() {
3287 // Zero with scale=2 must produce "0.00" (not "0")
3288 let decimal = SmallDecimal::new(0, 2, false);
3289 assert_eq!(decimal.to_string(), "0.00");
3290
3291 // Zero with scale=1
3292 let decimal = SmallDecimal::new(0, 1, false);
3293 assert_eq!(decimal.to_string(), "0.0");
3294
3295 // Zero with scale=4 and negative flag (normalizes sign away)
3296 let decimal = SmallDecimal::new(0, 4, true);
3297 assert_eq!(decimal.to_string(), "0.0000");
3298 }
3299
3300 proptest! {
3301 #![proptest_config(numeric_proptest_config())]
3302 #[test]
3303 fn prop_zoned_digit_buffer_contains_only_digits(
3304 digits_vec in prop::collection::vec(0u8..=9, 1..=12),
3305 signed in any::<bool>(),
3306 allow_negative in any::<bool>(),
3307 codepage in prop_oneof![
3308 Just(Codepage::ASCII),
3309 Just(Codepage::CP037),
3310 Just(Codepage::CP273),
3311 Just(Codepage::CP500),
3312 Just(Codepage::CP1047),
3313 Just(Codepage::CP1140),
3314 ],
3315 policy in prop_oneof![Just(ZeroSignPolicy::Positive), Just(ZeroSignPolicy::Preferred)],
3316 ) {
3317 let digit_count = u16::try_from(digits_vec.len()).expect("vector length <= 12");
3318 let mut bytes = Vec::with_capacity(digits_vec.len());
3319
3320 for digit in digits_vec.iter().take(digits_vec.len().saturating_sub(1)) {
3321 let byte = if codepage.is_ascii() {
3322 0x30 + digit
3323 } else {
3324 0xF0 + digit
3325 };
3326 bytes.push(byte);
3327 }
3328
3329 let is_negative = signed && allow_negative;
3330 let last_digit = *digits_vec.last().expect("vector is non-empty");
3331 let last_byte = if signed {
3332 let encoded = encode_overpunch_byte(last_digit, is_negative, codepage, policy)
3333 .expect("valid overpunch for digit 0-9");
3334 prop_assume!(is_valid_overpunch(encoded, codepage));
3335 encoded
3336 } else if codepage.is_ascii() {
3337 0x30 + last_digit
3338 } else {
3339 0xF0 + last_digit
3340 };
3341 bytes.push(last_byte);
3342
3343 let mut scratch = ScratchBuffers::new();
3344 let _ = decode_zoned_decimal_with_scratch(
3345 &bytes,
3346 digit_count,
3347 0,
3348 signed,
3349 codepage,
3350 false,
3351 &mut scratch,
3352 ).expect("decoding constructed zoned bytes should succeed");
3353
3354 prop_assert_eq!(scratch.digit_buffer.len(), digits_vec.len());
3355 prop_assert!(scratch.digit_buffer.iter().all(|&d| d <= 9));
3356 prop_assert_eq!(&scratch.digit_buffer[..], &digits_vec[..]);
3357 }
3358 }
3359
3360 #[test]
3361 fn test_zoned_decimal_blank_when_zero() {
3362 // EBCDIC spaces (0x40)
3363 let data = vec![0x40, 0x40, 0x40];
3364 let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, true).unwrap();
3365 assert_eq!(result.to_string(), "0");
3366
3367 // ASCII spaces
3368 let data = vec![b' ', b' ', b' '];
3369 let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::ASCII, true).unwrap();
3370 assert_eq!(result.to_string(), "0");
3371 }
3372
3373 #[test]
3374 fn test_packed_decimal_signs() {
3375 // Positive packed decimal: 123C (123 positive)
3376 let data = vec![0x12, 0x3C];
3377 let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
3378 assert_eq!(result.to_string(), "123");
3379
3380 // Negative packed decimal: 123D (123 negative)
3381 let data = vec![0x12, 0x3D];
3382 let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
3383 assert_eq!(result.to_string(), "-123");
3384
3385 // Test the failing case from property tests: -11 (2 digits)
3386 // Test that round-trip encoding/decoding preserves the sign
3387 let encoded = encode_packed_decimal("-11", 2, 0, true).unwrap();
3388 let result = decode_packed_decimal(&encoded, 2, 0, true).unwrap();
3389 assert_eq!(
3390 result.to_string(),
3391 "-11",
3392 "Failed to round-trip -11 correctly"
3393 );
3394
3395 // Test that the old buggy format is now rejected
3396 let data = vec![0x11, 0xDD]; // Invalid format with sign in both nibbles
3397 let result = decode_packed_decimal(&data, 2, 0, true);
3398 assert!(
3399 result.is_err(),
3400 "Should reject invalid format with sign in both nibbles"
3401 );
3402 }
3403
3404 #[test]
3405 fn test_binary_int_big_endian() {
3406 // 16-bit big-endian: 0x0123 = 291
3407 let data = vec![0x01, 0x23];
3408 let result = decode_binary_int(&data, 16, false).unwrap();
3409 assert_eq!(result, 291);
3410
3411 // 32-bit big-endian: 0x01234567 = 19088743
3412 let data = vec![0x01, 0x23, 0x45, 0x67];
3413 let result = decode_binary_int(&data, 32, false).unwrap();
3414 assert_eq!(result, 19_088_743);
3415 }
3416
3417 #[test]
3418 fn test_alphanumeric_encoding() {
3419 // ASCII encoding with padding
3420 let result = encode_alphanumeric("HELLO", 10, Codepage::ASCII).unwrap();
3421 assert_eq!(result, b"HELLO ");
3422
3423 // Over-length should error
3424 let result = encode_alphanumeric("HELLO WORLD", 5, Codepage::ASCII);
3425 assert!(result.is_err());
3426 }
3427
3428 #[test]
3429 fn test_bwz_policy() {
3430 // Zero values should trigger BWZ
3431 assert!(should_encode_as_blank_when_zero("0", true));
3432 assert!(should_encode_as_blank_when_zero("0.00", true));
3433 assert!(should_encode_as_blank_when_zero("0.000", true));
3434
3435 // Non-zero values should not trigger BWZ
3436 assert!(!should_encode_as_blank_when_zero("1", true));
3437 assert!(!should_encode_as_blank_when_zero("0.01", true));
3438
3439 // BWZ disabled should never trigger
3440 assert!(!should_encode_as_blank_when_zero("0", false));
3441 }
3442
3443 #[test]
3444 fn test_binary_width_mapping() {
3445 // Test digit-to-width mapping (NORMATIVE)
3446 assert_eq!(get_binary_width_from_digits(1), 16); // ≤4 → 2B
3447 assert_eq!(get_binary_width_from_digits(4), 16); // ≤4 → 2B
3448 assert_eq!(get_binary_width_from_digits(5), 32); // 5-9 → 4B
3449 assert_eq!(get_binary_width_from_digits(9), 32); // 5-9 → 4B
3450 assert_eq!(get_binary_width_from_digits(10), 64); // 10-18 → 8B
3451 assert_eq!(get_binary_width_from_digits(18), 64); // 10-18 → 8B
3452 }
3453
3454 #[test]
3455 fn test_explicit_binary_width_validation() {
3456 // Valid explicit widths
3457 assert_eq!(validate_explicit_binary_width(1).unwrap(), 8);
3458 assert_eq!(validate_explicit_binary_width(2).unwrap(), 16);
3459 assert_eq!(validate_explicit_binary_width(4).unwrap(), 32);
3460 assert_eq!(validate_explicit_binary_width(8).unwrap(), 64);
3461
3462 // Invalid explicit widths
3463 assert!(validate_explicit_binary_width(3).is_err());
3464 assert!(validate_explicit_binary_width(16).is_err());
3465 }
3466
3467 #[test]
3468 fn test_zoned_decimal_with_bwz() {
3469 // BWZ enabled with zero value should return spaces
3470 let result =
3471 encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, true).unwrap();
3472 assert_eq!(result, vec![b' ', b' ', b' ']);
3473
3474 // BWZ disabled with zero value should return normal encoding
3475 let result =
3476 encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, false).unwrap();
3477 assert_eq!(result, vec![0x30, 0x30, 0x30]); // ASCII "000"
3478
3479 // Non-zero value should return normal encoding regardless of BWZ
3480 let result =
3481 encode_zoned_decimal_with_bwz("123", 3, 0, false, Codepage::ASCII, true).unwrap();
3482 assert_eq!(result, vec![0x31, 0x32, 0x33]); // ASCII "123"
3483 }
3484
3485 #[test]
3486 fn test_error_handling_invalid_numeric_inputs() {
3487 // Test packed decimal with invalid input - should return specific CBKD error
3488 let invalid_data = vec![0xFF]; // Invalid packed decimal
3489 let result = decode_packed_decimal(&invalid_data, 2, 0, false);
3490 assert!(
3491 result.is_err(),
3492 "Invalid packed decimal should return error"
3493 );
3494
3495 let error = result.unwrap_err();
3496 assert!(
3497 error.to_string().contains("CBKD"),
3498 "Error should be CBKD code"
3499 );
3500
3501 // Test binary int with insufficient data
3502 let short_data = vec![0x01]; // Only 1 byte for 4-byte int
3503 let result = decode_binary_int(&short_data, 32, false);
3504 assert!(
3505 result.is_err(),
3506 "Insufficient binary data should return error"
3507 );
3508
3509 // Test zoned decimal with invalid characters
3510 let invalid_zoned = b"12X"; // Contains non-digit
3511 let result = decode_zoned_decimal(invalid_zoned, 3, 0, false, Codepage::ASCII, false);
3512 assert!(result.is_err(), "Invalid zoned decimal should return error");
3513
3514 // Test alphanumeric encoding with oversized input
3515 let result = encode_alphanumeric("TOOLONGFORFIELD", 5, Codepage::ASCII);
3516 assert!(
3517 result.is_err(),
3518 "Oversized alphanumeric should return error"
3519 );
3520
3521 let error = result.unwrap_err();
3522 assert!(
3523 error.to_string().contains("CBKE"),
3524 "Error should be CBKE code"
3525 );
3526 }
3527
3528 #[test]
3529 fn test_boundary_conditions_numeric_operations() {
3530 // Test maximum values for different data types
3531
3532 // Test maximum packed decimal
3533 let max_packed_bytes = vec![0x99, 0x9C]; // 999 positive (3 digits)
3534 let result = decode_packed_decimal(&max_packed_bytes, 3, 0, true);
3535 assert!(
3536 result.is_ok(),
3537 "Valid maximum packed decimal should succeed"
3538 );
3539
3540 // Test zero packed decimal
3541 let zero_packed = vec![0x00, 0x0C]; // 00 positive
3542 let result = decode_packed_decimal(&zero_packed, 2, 0, true);
3543 assert!(result.is_ok(), "Zero packed decimal should succeed");
3544
3545 // Test edge case with maximum binary values
3546 let max_u16_bytes = vec![0xFF, 0xFF];
3547 let result = decode_binary_int(&max_u16_bytes, 16, false);
3548 assert!(result.is_ok(), "Maximum unsigned 16-bit should succeed");
3549
3550 let max_signed_16_bytes = vec![0x7F, 0xFF];
3551 let result = decode_binary_int(&max_signed_16_bytes, 16, true);
3552 assert!(result.is_ok(), "Maximum signed 16-bit should succeed");
3553
3554 // Test edge case with minimum signed values
3555 let min_i16_bytes = vec![0x80, 0x00];
3556 let result = decode_binary_int(&min_i16_bytes, 16, true);
3557 assert!(result.is_ok(), "Minimum signed 16-bit should succeed");
3558 }
3559
3560 #[test]
3561 fn test_comp3_decimal_scale_fix() {
3562 // Test case for PIC S9(7)V99 COMP-3 with decimal positioning fix
3563 let input_value = "123.45";
3564 let digits = 9; // 7 integer + 2 decimal = 9 total digits
3565 let scale = 2; // 2 decimal places
3566 let signed = true;
3567
3568 // Test round-trip encoding/decoding
3569 let encoded_data = encode_packed_decimal(input_value, digits, scale, signed).unwrap();
3570 let decoded = decode_packed_decimal(&encoded_data, digits, scale, signed).unwrap();
3571
3572 assert_eq!(decoded.to_string(), "123.45", "COMP-3 round-trip failed");
3573
3574 // Test negative case
3575 let negative_value = "-999.99";
3576 let encoded_neg = encode_packed_decimal(negative_value, digits, scale, signed).unwrap();
3577 let decoded_neg = decode_packed_decimal(&encoded_neg, digits, scale, signed).unwrap();
3578
3579 assert_eq!(
3580 decoded_neg.to_string(),
3581 "-999.99",
3582 "Negative COMP-3 round-trip failed"
3583 );
3584 }
3585
3586 #[test]
3587 fn test_error_path_coverage_arithmetic_operations() {
3588 // Test SmallDecimal creation and basic operations
3589 let decimal = SmallDecimal::new(i64::MAX, 0, false);
3590 assert_eq!(decimal.value, i64::MAX);
3591 assert_eq!(decimal.scale, 0);
3592 assert!(!decimal.negative);
3593
3594 // Test boundary conditions for large values
3595 let large_decimal = SmallDecimal::new(999_999_999, 0, false);
3596 assert_eq!(large_decimal.value, 999_999_999);
3597
3598 // Test boundary conditions for scale normalization
3599 let mut small_decimal = SmallDecimal::new(1, 10, false);
3600 small_decimal.normalize(); // Should handle high scale
3601 assert!(small_decimal.scale >= 0);
3602
3603 // Test signed/unsigned conversions with boundary values
3604 let negative_decimal = SmallDecimal::new(-1, 0, true);
3605 assert!(
3606 negative_decimal.is_negative(),
3607 "Signed negative should be negative"
3608 );
3609
3610 let positive_decimal = SmallDecimal::new(1, 0, false);
3611 assert!(
3612 !positive_decimal.is_negative(),
3613 "Unsigned should not be negative"
3614 );
3615 }
3616}