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