Skip to main content

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}