copybook_codec/numeric/decimal.rs
1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Decimal value and zoned-encoding metadata helpers for numeric codecs.
3
4use crate::options::ZonedEncodingFormat;
5use copybook_core::{Error, ErrorCode, Result};
6use std::fmt::Write;
7
8pub(super) fn create_normalized_decimal(value: i64, scale: i16, is_negative: bool) -> SmallDecimal {
9 let mut decimal = SmallDecimal::new(value, scale, is_negative);
10 decimal.normalize();
11 decimal
12}
13
14/// Statistics collector for encoding analysis
15///
16/// Tracks the distribution of encoding formats within a zoned decimal field
17/// to determine the overall format and detect mixed encoding patterns.
18#[derive(Debug, Default)]
19#[allow(clippy::struct_field_names)] // Fields are descriptive counters
20struct EncodingAnalysisStats {
21 ascii_count: usize,
22 ebcdic_count: usize,
23 invalid_count: usize,
24}
25
26impl EncodingAnalysisStats {
27 /// Create a new statistics collector
28 fn new() -> Self {
29 Self::default()
30 }
31
32 /// Record a detected format in the statistics
33 fn record_format(&mut self, format: Option<ZonedEncodingFormat>) {
34 match format {
35 Some(ZonedEncodingFormat::Ascii) => self.ascii_count += 1,
36 Some(ZonedEncodingFormat::Ebcdic) => self.ebcdic_count += 1,
37 Some(ZonedEncodingFormat::Auto) => { /* Should not occur in detection */ }
38 None => self.invalid_count += 1,
39 }
40 }
41
42 /// Determine the overall format and mixed encoding status from collected statistics
43 ///
44 /// # Logic
45 /// - If invalid zones exist: Auto format with mixed encoding flag
46 /// - If both ASCII and EBCDIC zones exist: Auto format with mixed encoding flag
47 /// - If only ASCII zones: ASCII format, no mixing
48 /// - If only EBCDIC zones: EBCDIC format, no mixing
49 /// - If no valid zones: Auto format, no mixing (empty or all invalid)
50 fn determine_overall_format(&self) -> (ZonedEncodingFormat, bool) {
51 if self.invalid_count > 0 {
52 // Invalid zones detected - cannot determine format reliably
53 (ZonedEncodingFormat::Auto, true)
54 } else if self.ascii_count > 0 && self.ebcdic_count > 0 {
55 // Mixed ASCII and EBCDIC zones within the same field
56 (ZonedEncodingFormat::Auto, true)
57 } else if self.ascii_count > 0 {
58 // Consistent ASCII encoding throughout the field
59 (ZonedEncodingFormat::Ascii, false)
60 } else if self.ebcdic_count > 0 {
61 // Consistent EBCDIC encoding throughout the field
62 (ZonedEncodingFormat::Ebcdic, false)
63 } else {
64 // No valid zones found (empty field or all invalid)
65 (ZonedEncodingFormat::Auto, false)
66 }
67 }
68}
69
70/// Comprehensive encoding detection result for zoned decimal fields
71///
72/// Provides detailed analysis of zoned decimal encoding patterns within a field,
73/// enabling detection of mixed encodings and validation of data consistency.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct ZonedEncodingInfo {
76 /// Overall detected encoding format for the field
77 pub detected_format: ZonedEncodingFormat,
78 /// True if mixed ASCII/EBCDIC encoding was detected within the field
79 pub has_mixed_encoding: bool,
80 /// Per-byte encoding detection results for detailed analysis
81 pub byte_formats: Vec<Option<ZonedEncodingFormat>>,
82}
83
84impl ZonedEncodingInfo {
85 /// Create new encoding info with the specified format and mixed encoding status
86 ///
87 /// # Arguments
88 /// * `detected_format` - The overall encoding format determined for the field
89 /// * `has_mixed_encoding` - Whether mixed encoding patterns were detected
90 #[inline]
91 #[must_use]
92 pub fn new(detected_format: ZonedEncodingFormat, has_mixed_encoding: bool) -> Self {
93 Self {
94 detected_format,
95 has_mixed_encoding,
96 byte_formats: Vec::new(),
97 }
98 }
99
100 /// Analyze zoned decimal data bytes to detect encoding patterns
101 ///
102 /// Analyze bytes to identify the zoned encoding mix for downstream encode.
103 ///
104 /// # Errors
105 /// Returns an error if detection fails (currently never fails).
106 #[inline]
107 #[must_use = "Handle the Result or propagate the error"]
108 pub fn detect_from_data(data: &[u8]) -> Result<Self> {
109 if data.is_empty() {
110 return Ok(Self::new(ZonedEncodingFormat::Auto, false));
111 }
112
113 let mut byte_formats = Vec::with_capacity(data.len());
114 let mut encoding_stats = EncodingAnalysisStats::new();
115
116 // Analyze each byte's zone nibble for encoding patterns
117 for &byte in data {
118 let format = Self::analyze_zone_nibble(byte);
119 byte_formats.push(format);
120 encoding_stats.record_format(format);
121 }
122
123 // Determine overall encoding and mixed status from statistics
124 let (detected_format, has_mixed_encoding) = encoding_stats.determine_overall_format();
125
126 Ok(Self {
127 detected_format,
128 has_mixed_encoding,
129 byte_formats,
130 })
131 }
132
133 /// Analyze a single byte's zone nibble to determine its encoding format
134 ///
135 /// # Zone Nibble Analysis
136 /// - `0x3`: ASCII digit zone (0x30-0x39 range)
137 /// - `0xF`: EBCDIC digit zone (0xF0-0xF9 range)
138 /// - ASCII overpunch characters: 0x4X, 0x7B, 0x7D (sign encoding)
139 /// - Others: Invalid or non-standard zones
140 fn analyze_zone_nibble(byte: u8) -> Option<ZonedEncodingFormat> {
141 const ASCII_ZONE: u8 = 0x3;
142 const EBCDIC_ZONE: u8 = 0xF;
143 const ZONE_MASK: u8 = 0x0F;
144
145 // Check for specific ASCII overpunch characters first
146 match byte {
147 // ASCII overpunch sign bytes and overpunch characters (A-I, J-R)
148 0x7B | 0x7D | 0x41..=0x52 => return Some(ZonedEncodingFormat::Ascii),
149 _ => {}
150 }
151
152 let zone_nibble = (byte >> 4) & ZONE_MASK;
153 match zone_nibble {
154 ASCII_ZONE => Some(ZonedEncodingFormat::Ascii),
155 EBCDIC_ZONE | 0xC | 0xD => Some(ZonedEncodingFormat::Ebcdic),
156 _ => None, // Invalid or mixed zone
157 }
158 }
159}
160
161/// Small decimal structure for parsing/formatting without floats
162/// This avoids floating-point precision issues for financial data
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct SmallDecimal {
165 /// The integer value (unscaled)
166 pub value: i64,
167 /// The scale (number of decimal places)
168 pub scale: i16,
169 /// Whether the value is negative
170 pub negative: bool,
171}
172
173impl SmallDecimal {
174 /// Create a new `SmallDecimal`.
175 #[inline]
176 #[must_use]
177 pub fn new(value: i64, scale: i16, negative: bool) -> Self {
178 Self {
179 value,
180 scale,
181 negative,
182 }
183 }
184
185 /// Create a zero value with the given scale
186 #[inline]
187 #[must_use]
188 pub fn zero(scale: i16) -> Self {
189 Self {
190 value: 0,
191 scale,
192 negative: false,
193 }
194 }
195
196 /// Normalize -0 to 0 (NORMATIVE)
197 #[inline]
198 pub fn normalize(&mut self) {
199 if self.value == 0 {
200 self.negative = false;
201 }
202 }
203
204 /// Format as string with fixed scale (NORMATIVE)
205 ///
206 /// Always render with exactly `scale` digits after decimal point.
207 #[allow(clippy::inherent_to_string)] // Intentional - this is a specific numeric formatting
208 #[inline]
209 #[must_use = "Use the formatted string output"]
210 pub fn to_string(&self) -> String {
211 let mut result = String::new();
212 self.append_sign_if_negative(&mut result);
213 self.append_formatted_value(&mut result);
214 result
215 }
216
217 /// Check if this decimal represents a zero value
218 fn is_zero_value(&self) -> bool {
219 self.value == 0
220 }
221
222 /// Append negative sign to the result if the value is negative and non-zero
223 fn append_sign_if_negative(&self, result: &mut String) {
224 if self.negative && !self.is_zero_value() {
225 result.push('-');
226 }
227 }
228
229 /// Append the formatted numeric value (integer or decimal) to the result
230 fn append_formatted_value(&self, result: &mut String) {
231 if self.scale <= 0 {
232 self.append_integer_format(result);
233 } else {
234 self.append_decimal_format(result);
235 }
236 }
237
238 /// Append integer format or scale extension to the result
239 fn append_integer_format(&self, result: &mut String) {
240 let scaled_value = if self.scale < 0 {
241 // Scale extension: multiply by 10^(-scale)
242 self.value * 10_i64.pow(scale_abs_to_u32(self.scale))
243 } else {
244 // Normal integer format (scale = 0)
245 self.value
246 };
247 // Writing to String should never fail, but handle gracefully for panic elimination
248 if write!(result, "{scaled_value}").is_err() {
249 // Fallback: append a placeholder if formatting somehow fails
250 result.push_str("ERR");
251 }
252 }
253
254 /// Append decimal format with exactly `scale` digits after decimal point
255 fn append_decimal_format(&self, result: &mut String) {
256 let divisor = 10_i64.pow(scale_abs_to_u32(self.scale));
257 let integer_part = self.value / divisor;
258 let fractional_part = self.value % divisor;
259
260 // Writing to String should never fail, but handle gracefully for panic elimination
261 let width = usize::try_from(self.scale).unwrap_or_else(|_| {
262 debug_assert!(false, "scale should be positive when formatting decimal");
263 0
264 });
265
266 if write!(result, "{integer_part}.{fractional_part:0width$}").is_err() {
267 // Fallback: append a placeholder if formatting somehow fails
268 result.push_str("ERR");
269 }
270 }
271
272 /// Parse a decimal string into a `SmallDecimal` using the expected scale.
273 ///
274 /// # Errors
275 /// Returns an error when the text violates the expected numeric format.
276 #[inline]
277 #[must_use = "Handle the Result or propagate the error"]
278 pub fn from_str(s: &str, expected_scale: i16) -> Result<Self> {
279 let trimmed = s.trim();
280 if trimmed.is_empty() {
281 return Ok(Self::zero(expected_scale));
282 }
283
284 let (negative, numeric_part) = Self::extract_sign(trimmed);
285
286 if let Some(dot_pos) = numeric_part.find('.') {
287 Self::parse_decimal_format(numeric_part, dot_pos, expected_scale, negative)
288 } else {
289 Self::parse_integer_format(numeric_part, expected_scale, negative)
290 }
291 }
292
293 /// Extract sign information from the numeric string
294 ///
295 /// Returns (`is_negative`, `numeric_part_without_sign`).
296 fn extract_sign(s: &str) -> (bool, &str) {
297 if let Some(without_minus) = s.strip_prefix('-') {
298 (true, without_minus)
299 } else {
300 (false, s)
301 }
302 }
303
304 /// Parse decimal format string (contains decimal point)
305 fn parse_decimal_format(
306 numeric_part: &str,
307 dot_pos: usize,
308 expected_scale: i16,
309 negative: bool,
310 ) -> Result<Self> {
311 let integer_part = &numeric_part[..dot_pos];
312 let fractional_part = &numeric_part[dot_pos + 1..];
313
314 // Validate scale matches exactly (NORMATIVE)
315 let expected_len = usize::try_from(expected_scale).map_err(|_| {
316 Error::new(
317 ErrorCode::CBKE505_SCALE_MISMATCH,
318 format!(
319 "Scale mismatch: expected {expected_scale} decimal places, got {}",
320 fractional_part.len()
321 ),
322 )
323 })?;
324
325 if fractional_part.len() != expected_len {
326 return Err(Error::new(
327 ErrorCode::CBKE505_SCALE_MISMATCH,
328 format!(
329 "Scale mismatch: expected {expected_scale} decimal places, got {}",
330 fractional_part.len()
331 ),
332 ));
333 }
334
335 let integer_value = Self::parse_integer_component(integer_part)?;
336 let fractional_value = Self::parse_integer_component(fractional_part)?;
337 let total_value =
338 Self::combine_integer_and_fractional(integer_value, fractional_value, expected_scale)?;
339
340 let mut result = Self::new(total_value, expected_scale, negative);
341 result.normalize();
342 Ok(result)
343 }
344
345 /// Parse integer format string (no decimal point)
346 fn parse_integer_format(
347 numeric_part: &str,
348 expected_scale: i16,
349 negative: bool,
350 ) -> Result<Self> {
351 // Defense-in-depth: accept bare "0" for scaled fields (e.g., user-provided JSON)
352 if expected_scale != 0 {
353 let value = Self::parse_integer_component(numeric_part)?;
354 if value == 0 {
355 let mut result = Self::new(0, expected_scale, negative);
356 result.normalize();
357 return Ok(result);
358 }
359 return Err(Error::new(
360 ErrorCode::CBKE505_SCALE_MISMATCH,
361 format!("Scale mismatch: expected {expected_scale} decimal places, got integer"),
362 ));
363 }
364
365 let value = Self::parse_integer_component(numeric_part)?;
366 let mut result = Self::new(value, expected_scale, negative);
367 result.normalize();
368 Ok(result)
369 }
370
371 /// Parse a string component as an integer with error handling
372 fn parse_integer_component(s: &str) -> Result<i64> {
373 s.parse().map_err(|_| {
374 Error::new(
375 ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
376 format!("Invalid numeric component: '{s}'"),
377 )
378 })
379 }
380
381 /// Combine integer and fractional parts into a scaled value
382 fn combine_integer_and_fractional(
383 integer_value: i64,
384 fractional_value: i64,
385 scale: i16,
386 ) -> Result<i64> {
387 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
388 integer_value
389 .checked_mul(divisor)
390 .and_then(|v| v.checked_add(fractional_value))
391 .ok_or_else(|| {
392 Error::new(
393 ErrorCode::CBKE510_NUMERIC_OVERFLOW,
394 "Numeric value too large - would cause overflow",
395 )
396 })
397 }
398
399 /// Format as string with the given fixed scale.
400 ///
401 /// Always renders with exactly `scale` digits after the decimal point,
402 /// independent of the `SmallDecimal`'s own scale. Used for lossless JSON output.
403 #[inline]
404 #[must_use]
405 pub fn to_fixed_scale_string(&self, scale: i16) -> String {
406 let mut result = String::new();
407
408 if self.negative && self.value != 0 {
409 result.push('-');
410 }
411
412 if scale <= 0 {
413 // Integer format (scale=0) or scale extension
414 let scaled_value = if scale < 0 {
415 self.value * 10_i64.pow(scale_abs_to_u32(scale))
416 } else {
417 self.value
418 };
419 // Writing to String should never fail, but handle gracefully for panic elimination
420 if write!(result, "{scaled_value}").is_err() {
421 result.push_str("ERR");
422 }
423 } else {
424 // Decimal format with exactly `scale` digits after decimal
425 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
426 let integer_part = self.value / divisor;
427 let fractional_part = self.value % divisor;
428
429 // Writing to String should never fail, but handle gracefully for panic elimination
430 let width = usize::try_from(scale).unwrap_or_else(|_| {
431 debug_assert!(false, "scale should be positive in decimal formatting");
432 0
433 });
434
435 if write!(result, "{integer_part}.{fractional_part:0width$}").is_err() {
436 result.push_str("ERR");
437 }
438 }
439
440 result
441 }
442
443 /// Format the decimal into a caller-owned string buffer to avoid allocation.
444 ///
445 /// This is the hot-path formatter used in COMP-3 JSON conversion. The buffer
446 /// is cleared before writing.
447 #[inline]
448 pub fn format_to_scratch_buffer(&self, scale: i16, scratch_buffer: &mut String) {
449 scratch_buffer.clear();
450
451 if self.negative && self.value != 0 {
452 scratch_buffer.push('-');
453 }
454
455 if scale <= 0 {
456 // Integer format (scale=0) or scale extension
457 let scaled_value = if scale < 0 {
458 self.value * 10_i64.pow(scale_abs_to_u32(scale))
459 } else {
460 self.value
461 };
462 // CRITICAL OPTIMIZATION: Manual integer formatting to avoid write!() overhead
463 Self::format_integer_manual(scaled_value, scratch_buffer);
464 } else {
465 // Decimal format with exactly `scale` digits after decimal
466 let divisor = 10_i64.pow(scale_abs_to_u32(scale));
467 let integer_part = self.value / divisor;
468 let fractional_part = self.value % divisor;
469
470 // CRITICAL OPTIMIZATION: Manual decimal formatting to avoid write!() overhead
471 Self::format_integer_manual(integer_part, scratch_buffer);
472 scratch_buffer.push('.');
473 Self::format_integer_with_leading_zeros(
474 fractional_part,
475 scale_abs_to_u32(scale),
476 scratch_buffer,
477 );
478 }
479 }
480
481 /// Ultra-fast manual integer formatting to avoid write!() macro overhead
482 #[inline]
483 pub(super) fn format_integer_manual(mut value: i64, buffer: &mut String) {
484 if value == 0 {
485 buffer.push('0');
486 return;
487 }
488
489 // Optimized formatting with fewer divisions for common cases
490 if value < 100 {
491 // Fast path for 1-2 digit numbers (very common in COMP-3)
492 if value < 10 {
493 push_digit(buffer, value);
494 } else {
495 let tens = value / 10;
496 let ones = value % 10;
497 push_digit(buffer, tens);
498 push_digit(buffer, ones);
499 }
500 return;
501 }
502
503 // Use a small stack buffer for digits for larger numbers
504 let mut digits = [0u8; 20]; // More than enough for i64::MAX
505 let mut count = 0;
506
507 // Safe: i64::MAX has 19 digits, array has 20 elements
508 while value > 0 && count < 20 {
509 digits[count] = digit_from_value(value % 10);
510 value /= 10;
511 count += 1;
512 }
513
514 // Add digits in reverse order
515 for i in (0..count).rev() {
516 buffer.push(char::from(b'0' + digits[i]));
517 }
518 }
519
520 /// Ultra-fast manual integer formatting with leading zeros
521 #[inline]
522 pub(super) fn format_integer_with_leading_zeros(
523 mut value: i64,
524 width: u32,
525 buffer: &mut String,
526 ) {
527 // Optimized for common small widths (most COMP-3 scales are 0-4)
528 if width <= 4 && value < 10000 {
529 match width {
530 1 => {
531 push_digit(buffer, value);
532 }
533 2 => {
534 push_digit(buffer, value / 10);
535 push_digit(buffer, value % 10);
536 }
537 3 => {
538 push_digit(buffer, value / 100);
539 push_digit(buffer, (value / 10) % 10);
540 push_digit(buffer, value % 10);
541 }
542 4 => {
543 push_digit(buffer, value / 1000);
544 push_digit(buffer, (value / 100) % 10);
545 push_digit(buffer, (value / 10) % 10);
546 push_digit(buffer, value % 10);
547 }
548 _ => {}
549 }
550 return;
551 }
552
553 // General case for larger widths
554 let mut digits = [0u8; 20]; // More than enough for i64::MAX
555 let mut count = 0;
556 // Clamp target_width to array size to prevent out-of-bounds access
557 let target_width = usize::try_from(width).unwrap_or(usize::MAX).min(20);
558
559 // Extract digits (safe: i64::MAX has at most 19 digits, array has 20 elements)
560 loop {
561 digits[count] = digit_from_value(value % 10);
562 value /= 10;
563 count += 1;
564 // Safety: count is bounded by min(19, target_width) where target_width <= 20
565 if value == 0 && count >= target_width {
566 break;
567 }
568 if count >= 20 {
569 // Defensive: should never happen for i64, but prevents any overflow
570 break;
571 }
572 }
573
574 // Pad with leading zeros if needed (count is guaranteed <= 20)
575 while count < target_width {
576 digits[count] = 0;
577 count += 1;
578 }
579
580 // Add digits in reverse order
581 for i in (0..count).rev() {
582 buffer.push(char::from(b'0' + digits[i]));
583 }
584 }
585
586 /// Get the scale of this decimal
587 #[inline]
588 #[must_use]
589 pub fn scale(&self) -> i16 {
590 self.scale
591 }
592
593 /// Check if this decimal is negative
594 #[inline]
595 #[must_use]
596 pub fn is_negative(&self) -> bool {
597 self.negative && self.value != 0
598 }
599
600 /// Get the total number of digits in this decimal
601 #[inline]
602 #[must_use]
603 pub fn total_digits(&self) -> u16 {
604 if self.value == 0 {
605 return 1;
606 }
607
608 let mut count = 0;
609 let mut val = self.value.abs();
610 while val > 0 {
611 count += 1;
612 val /= 10;
613 }
614 count
615 }
616}
617
618/// Convert an integer value to a single digit (0-9)
619///
620/// Validates that the value is in the range 0-9 and converts it to a u8 digit.
621/// Returns 0 for invalid inputs (with debug assertion).
622///
623/// # Arguments
624/// * `value` - Integer value to convert (expected to be 0-9)
625///
626/// # Returns
627/// A single digit as u8, or 0 if the value is out of range
628///
629/// # Safety
630/// This function includes a debug assertion that fires if the value is out of
631/// range. In release builds, invalid values return 0.
632///
633/// # Performance
634/// Used in hot paths for packed decimal encoding where digits are extracted
635/// from numeric values.
636#[inline]
637pub(super) fn digit_from_value(value: i64) -> u8 {
638 match u8::try_from(value) {
639 Ok(digit) if digit <= 9 => digit,
640 _ => {
641 debug_assert!(false, "digit out of range: {value}");
642 0
643 }
644 }
645}
646
647/// Push a single digit character to a string buffer
648///
649/// Converts an integer digit (0-9) to its ASCII character representation and
650/// appends it to the buffer.
651///
652/// # Arguments
653/// * `buffer` - String buffer to append to
654/// * `digit` - Integer digit value (0-9)
655///
656/// # Performance
657/// Inline function optimized for decimal formatting hot paths in COMP-3 decoding.
658#[inline]
659fn push_digit(buffer: &mut String, digit: i64) {
660 buffer.push(char::from(b'0' + digit_from_value(digit)));
661}
662
663/// Convert absolute scale value to u32 for power calculations
664///
665/// Takes the absolute value of a scale (i16) and converts it to u32 for use
666/// in power-of-10 calculations.
667///
668/// # Arguments
669/// * `scale` - Scale value (can be negative for integer extensions)
670///
671/// # Returns
672/// Absolute value of scale as u32
673///
674/// # Examples
675/// ```
676/// # fn scale_abs_to_u32(scale: i16) -> u32 { u32::from(scale.unsigned_abs()) }
677/// assert_eq!(scale_abs_to_u32(2), 2);
678/// assert_eq!(scale_abs_to_u32(-2), 2);
679/// assert_eq!(scale_abs_to_u32(0), 0);
680/// ```
681#[inline]
682pub(super) fn scale_abs_to_u32(scale: i16) -> u32 {
683 u32::from(scale.unsigned_abs())
684}