Skip to main content

copybook_codec/
edited_pic.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Edited PIC (Phase E2 + E3) decode and encode support
3//!
4//! This module implements decode and encode for edited numeric PICTURE clauses following IBM COBOL specifications.
5//! Edited PICs include formatting symbols like Z (zero suppression), $ (currency), comma, decimal point,
6//! B (blank space insertion), and sign editing (+, -, CR, DB).
7//!
8//! The decode algorithm walks the input string and PIC pattern in lockstep, extracting numeric digits
9//! and validating formatting symbols. The encode algorithm formats numeric values according to the pattern.
10
11use copybook_core::{Error, ErrorCode, Result};
12use tracing::warn;
13
14/// Pattern tokens for edited PIC clauses.
15///
16/// Each variant represents a formatting symbol in an edited PICTURE clause,
17/// used during decode and encode of edited numeric fields.
18#[derive(Debug, Clone, PartialEq)]
19pub enum PicToken {
20    /// Numeric digit (9) - always displays
21    Digit,
22    /// Zero suppression (Z) - displays space if leading zero
23    ZeroSuppress,
24    /// Zero insert (0) - always displays '0'
25    ZeroInsert,
26    /// Asterisk fill (*) - displays '*' for leading zeros
27    AsteriskFill,
28    /// Blank space (B)
29    Space,
30    /// Literal comma
31    Comma,
32    /// Literal slash
33    Slash,
34    /// Decimal point
35    DecimalPoint,
36    /// Currency symbol ($)
37    Currency,
38    /// Leading plus sign
39    LeadingPlus,
40    /// Leading minus sign
41    LeadingMinus,
42    /// Trailing plus sign
43    TrailingPlus,
44    /// Trailing minus sign
45    TrailingMinus,
46    /// Credit (CR) - two characters
47    Credit,
48    /// Debit (DB) - two characters
49    Debit,
50}
51
52impl std::fmt::Display for PicToken {
53    #[inline]
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::Digit => write!(f, "9"),
57            Self::ZeroSuppress => write!(f, "Z"),
58            Self::ZeroInsert => write!(f, "0"),
59            Self::AsteriskFill => write!(f, "*"),
60            Self::Space => write!(f, "B"),
61            Self::Comma => write!(f, ","),
62            Self::Slash => write!(f, "/"),
63            Self::DecimalPoint => write!(f, "."),
64            Self::Currency => write!(f, "$"),
65            Self::LeadingPlus | Self::TrailingPlus => write!(f, "+"),
66            Self::LeadingMinus | Self::TrailingMinus => write!(f, "-"),
67            Self::Credit => write!(f, "CR"),
68            Self::Debit => write!(f, "DB"),
69        }
70    }
71}
72
73/// Sign extracted from an edited PIC field during decode.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub enum Sign {
76    /// Positive or unsigned value.
77    Positive,
78    /// Negative value.
79    Negative,
80}
81
82/// Tokenize an edited PIC pattern into tokens
83///
84/// # Errors
85/// Returns error if the PIC pattern is malformed
86#[inline]
87#[allow(clippy::too_many_lines)]
88#[must_use = "Handle the Result or propagate the error"]
89pub fn tokenize_edited_pic(pic_str: &str) -> Result<Vec<PicToken>> {
90    let mut tokens = Vec::new();
91    let mut chars = pic_str.chars().peekable();
92    let mut found_decimal = false;
93
94    // Skip leading 'S' if present (sign is handled separately via sign editing symbols)
95    if chars.peek() == Some(&'S') || chars.peek() == Some(&'s') {
96        chars.next();
97    }
98
99    while let Some(ch) = chars.next() {
100        match ch.to_ascii_uppercase() {
101            '9' => {
102                let count = parse_repetition(&mut chars)?;
103                for _ in 0..count {
104                    tokens.push(PicToken::Digit);
105                }
106            }
107            'Z' => {
108                let count = parse_repetition(&mut chars)?;
109                for _ in 0..count {
110                    tokens.push(PicToken::ZeroSuppress);
111                }
112            }
113            '0' => {
114                let count = parse_repetition(&mut chars)?;
115                for _ in 0..count {
116                    tokens.push(PicToken::ZeroInsert);
117                }
118            }
119            '*' => {
120                let count = parse_repetition(&mut chars)?;
121                for _ in 0..count {
122                    tokens.push(PicToken::AsteriskFill);
123                }
124            }
125            'B' => {
126                let count = parse_repetition(&mut chars)?;
127                for _ in 0..count {
128                    tokens.push(PicToken::Space);
129                }
130            }
131            ',' => tokens.push(PicToken::Comma),
132            '/' => tokens.push(PicToken::Slash),
133            '.' => {
134                if found_decimal {
135                    return Err(Error::new(
136                        ErrorCode::CBKP001_SYNTAX,
137                        format!("Multiple decimal points in edited PIC: {pic_str}"),
138                    ));
139                }
140                found_decimal = true;
141                tokens.push(PicToken::DecimalPoint);
142            }
143            '$' => tokens.push(PicToken::Currency),
144            '+' => {
145                // Check if at beginning (leading) or end (trailing)
146                if tokens.is_empty() {
147                    tokens.push(PicToken::LeadingPlus);
148                } else {
149                    tokens.push(PicToken::TrailingPlus);
150                }
151            }
152            '-' => {
153                // Check if at beginning (leading) or end (trailing)
154                if tokens.is_empty() {
155                    tokens.push(PicToken::LeadingMinus);
156                } else {
157                    tokens.push(PicToken::TrailingMinus);
158                }
159            }
160            'C' => {
161                // Check for CR
162                if let Some(&next_ch) = chars.peek()
163                    && (next_ch == 'R' || next_ch == 'r')
164                {
165                    chars.next(); // consume 'R'
166                    tokens.push(PicToken::Credit);
167                } else {
168                    return Err(Error::new(
169                        ErrorCode::CBKP001_SYNTAX,
170                        format!("Invalid character 'C' in edited PIC: {pic_str}"),
171                    ));
172                }
173            }
174            'D' => {
175                // Check for DB
176                if let Some(&next_ch) = chars.peek()
177                    && (next_ch == 'B' || next_ch == 'b')
178                {
179                    chars.next(); // consume 'B'
180                    tokens.push(PicToken::Debit);
181                } else {
182                    return Err(Error::new(
183                        ErrorCode::CBKP001_SYNTAX,
184                        format!("Invalid character 'D' in edited PIC: {pic_str}"),
185                    ));
186                }
187            }
188            _ => {
189                // Skip implicit decimal point markers (V), whitespace, or unknown characters
190            }
191        }
192    }
193
194    if tokens.is_empty() {
195        return Err(Error::new(
196            ErrorCode::CBKP001_SYNTAX,
197            format!("Empty or invalid edited PIC pattern: {pic_str}"),
198        ));
199    }
200
201    Ok(tokens)
202}
203
204/// Parse repetition count from chars like (5)
205fn parse_repetition<I>(chars: &mut std::iter::Peekable<I>) -> Result<usize>
206where
207    I: Iterator<Item = char>,
208{
209    if chars.peek() == Some(&'(') {
210        chars.next(); // consume '('
211        let mut count_str = String::new();
212        while let Some(&ch) = chars.peek() {
213            if ch == ')' {
214                chars.next(); // consume ')'
215                break;
216            } else if ch.is_ascii_digit() {
217                count_str.push(ch);
218                chars.next();
219            } else {
220                return Err(Error::new(
221                    ErrorCode::CBKP001_SYNTAX,
222                    format!("Invalid repetition count: {count_str}"),
223                ));
224            }
225        }
226        count_str.parse::<usize>().map_err(|_| {
227            Error::new(
228                ErrorCode::CBKP001_SYNTAX,
229                format!("Invalid repetition count: {count_str}"),
230            )
231        })
232    } else {
233        Ok(1)
234    }
235}
236
237/// Decoded numeric value extracted from an edited PIC field.
238///
239/// Contains the sign, raw digit string, and scale needed to
240/// produce a JSON numeric representation.
241#[derive(Debug, Clone, PartialEq)]
242pub struct NumericValue {
243    /// Sign of the number
244    pub sign: Sign,
245    /// Digits without decimal point (e.g., "12345" for 123.45 with scale=2)
246    pub digits: String,
247    /// Number of decimal places
248    pub scale: u16,
249}
250
251impl NumericValue {
252    /// Format as decimal string for JSON output
253    #[must_use]
254    #[inline]
255    pub fn to_decimal_string(&self) -> String {
256        if self.digits.is_empty() || self.digits.chars().all(|c| c == '0') {
257            return "0".to_string();
258        }
259
260        let sign_prefix = match self.sign {
261            Sign::Positive => "",
262            Sign::Negative => "-",
263        };
264
265        if self.scale == 0 {
266            // Integer - just return digits with sign
267            format!("{sign_prefix}{}", self.digits)
268        } else {
269            // Decimal - insert decimal point
270            let scale = self.scale as usize;
271            let digits_len = self.digits.len();
272
273            if scale >= digits_len {
274                // Need leading zeros (e.g., 0.0123)
275                let zeros = "0".repeat(scale - digits_len);
276                format!("{sign_prefix}0.{zeros}{}", self.digits)
277            } else {
278                // Split at decimal point
279                let (int_part, frac_part) = self.digits.split_at(digits_len - scale);
280                if int_part.is_empty() {
281                    format!("{sign_prefix}0.{frac_part}")
282                } else {
283                    format!("{sign_prefix}{int_part}.{frac_part}")
284                }
285            }
286        }
287    }
288}
289
290/// Decode edited numeric string according to PICTURE pattern
291///
292/// # Errors
293/// Returns error if the input doesn't match the pattern
294#[inline]
295#[allow(clippy::too_many_lines)]
296#[must_use = "Handle the Result or propagate the error"]
297pub fn decode_edited_numeric(
298    input: &str,
299    pattern: &[PicToken],
300    scale: u16,
301    blank_when_zero: bool,
302) -> Result<NumericValue> {
303    // Check for BLANK WHEN ZERO
304    if blank_when_zero && input.chars().all(|c| c == ' ') {
305        warn!("CBKD423_EDITED_PIC_BLANK_WHEN_ZERO: Edited PIC field is blank, decoding as zero");
306        crate::lib_api::increment_warning_counter();
307        return Ok(NumericValue {
308            sign: Sign::Positive,
309            digits: "0".to_string(),
310            scale,
311        });
312    }
313
314    let input_chars: Vec<char> = input.chars().collect();
315    let mut pattern_idx = 0;
316    let mut input_idx = 0;
317    let mut digits = String::new();
318    let mut sign = Sign::Positive;
319    let mut found_non_zero = false;
320
321    // Extract sign from pattern and input
322    while pattern_idx < pattern.len() {
323        let token = &pattern[pattern_idx];
324
325        if input_idx >= input_chars.len() {
326            return Err(Error::new(
327                ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
328                format!(
329                    "Input too short for edited PIC pattern (expected {} characters, got {})",
330                    pattern.len(),
331                    input.len()
332                ),
333            ));
334        }
335
336        let input_char = input_chars[input_idx];
337
338        match token {
339            PicToken::Digit
340            | PicToken::ZeroSuppress
341            | PicToken::ZeroInsert
342            | PicToken::AsteriskFill => {
343                // Expect digit or space or asterisk
344                if input_char.is_ascii_digit() {
345                    let digit_val = input_char;
346                    if digit_val != '0' {
347                        found_non_zero = true;
348                    }
349                    if found_non_zero || matches!(token, PicToken::Digit | PicToken::ZeroInsert) {
350                        digits.push(digit_val);
351                    } else {
352                        // Leading zero suppression - push 0 to maintain position
353                        digits.push('0');
354                    }
355                } else if input_char == ' ' {
356                    // Space for zero suppression
357                    if matches!(token, PicToken::Digit) {
358                        // Required digit position cannot be space
359                        return Err(Error::new(
360                            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
361                            format!("Expected digit but found space at position {input_idx}"),
362                        ));
363                    }
364                    digits.push('0');
365                } else if input_char == '*' {
366                    // Asterisk fill for check protection
367                    if !matches!(token, PicToken::AsteriskFill) {
368                        return Err(Error::new(
369                            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
370                            format!("Unexpected asterisk at position {input_idx}"),
371                        ));
372                    }
373                    digits.push('0');
374                } else {
375                    return Err(Error::new(
376                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
377                        format!(
378                            "Expected digit, space, or asterisk but found '{input_char}' at position {input_idx}"
379                        ),
380                    ));
381                }
382                input_idx += 1;
383            }
384            PicToken::Space => {
385                if input_char != ' ' && input_char != 'B' {
386                    return Err(Error::new(
387                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
388                        format!("Expected space but found '{input_char}' at position {input_idx}"),
389                    ));
390                }
391                input_idx += 1;
392            }
393            PicToken::Comma => {
394                if input_char != ',' {
395                    return Err(Error::new(
396                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
397                        format!("Expected comma but found '{input_char}' at position {input_idx}"),
398                    ));
399                }
400                input_idx += 1;
401            }
402            PicToken::Slash => {
403                if input_char != '/' {
404                    return Err(Error::new(
405                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
406                        format!("Expected slash but found '{input_char}' at position {input_idx}"),
407                    ));
408                }
409                input_idx += 1;
410            }
411            PicToken::DecimalPoint => {
412                if input_char != '.' {
413                    return Err(Error::new(
414                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
415                        format!(
416                            "Expected decimal point but found '{input_char}' at position {input_idx}"
417                        ),
418                    ));
419                }
420                input_idx += 1;
421            }
422            PicToken::Currency => {
423                if input_char != '$' && input_char != ' ' {
424                    return Err(Error::new(
425                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
426                        format!(
427                            "Expected currency symbol but found '{input_char}' at position {input_idx}"
428                        ),
429                    ));
430                }
431                input_idx += 1;
432            }
433            PicToken::LeadingPlus => {
434                if input_char == '+' || input_char == ' ' {
435                    sign = Sign::Positive;
436                } else if input_char == '-' {
437                    sign = Sign::Negative;
438                } else {
439                    return Err(Error::new(
440                        ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH,
441                        format!("Expected '+' or '-' for leading plus but found '{input_char}'"),
442                    ));
443                }
444                input_idx += 1;
445            }
446            PicToken::LeadingMinus => {
447                if input_char == '-' {
448                    sign = Sign::Negative;
449                } else if input_char == ' ' {
450                    sign = Sign::Positive;
451                } else {
452                    return Err(Error::new(
453                        ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH,
454                        format!("Expected '-' for leading minus but found '{input_char}'"),
455                    ));
456                }
457                input_idx += 1;
458            }
459            PicToken::TrailingPlus => {
460                if input_char == '+' || input_char == ' ' {
461                    sign = Sign::Positive;
462                } else if input_char == '-' {
463                    sign = Sign::Negative;
464                } else {
465                    return Err(Error::new(
466                        ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH,
467                        format!("Expected '+' or '-' for trailing plus but found '{input_char}'"),
468                    ));
469                }
470                input_idx += 1;
471            }
472            PicToken::TrailingMinus => {
473                if input_char == '-' {
474                    sign = Sign::Negative;
475                } else if input_char == ' ' {
476                    sign = Sign::Positive;
477                } else {
478                    return Err(Error::new(
479                        ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH,
480                        format!("Expected '-' for trailing minus but found '{input_char}'"),
481                    ));
482                }
483                input_idx += 1;
484            }
485            PicToken::Credit => {
486                // CR requires two characters
487                if input_idx + 1 >= input_chars.len() {
488                    return Err(Error::new(
489                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
490                        "Input too short for CR symbol".to_string(),
491                    ));
492                }
493                let cr_str: String = input_chars[input_idx..input_idx + 2].iter().collect();
494                if cr_str == "CR" {
495                    sign = Sign::Negative;
496                } else if cr_str == "  " {
497                    sign = Sign::Positive;
498                } else {
499                    return Err(Error::new(
500                        ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH,
501                        format!("Expected 'CR' or spaces but found '{cr_str}'"),
502                    ));
503                }
504                input_idx += 2;
505            }
506            PicToken::Debit => {
507                // DB requires two characters
508                if input_idx + 1 >= input_chars.len() {
509                    return Err(Error::new(
510                        ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
511                        "Input too short for DB symbol".to_string(),
512                    ));
513                }
514                let db_str: String = input_chars[input_idx..input_idx + 2].iter().collect();
515                if db_str == "DB" {
516                    sign = Sign::Negative;
517                } else if db_str == "  " {
518                    sign = Sign::Positive;
519                } else {
520                    return Err(Error::new(
521                        ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH,
522                        format!("Expected 'DB' or spaces but found '{db_str}'"),
523                    ));
524                }
525                input_idx += 2;
526            }
527        }
528
529        pattern_idx += 1;
530    }
531
532    // Check if we consumed all input
533    if input_idx != input_chars.len() {
534        return Err(Error::new(
535            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
536            format!(
537                "Input longer than expected (pattern consumed {} chars, input has {} chars)",
538                input_idx,
539                input_chars.len()
540            ),
541        ));
542    }
543
544    // Clean up digits - remove leading zeros but preserve at least one digit
545    let digits = digits.trim_start_matches('0');
546    let digits = if digits.is_empty() {
547        "0".to_string()
548    } else {
549        digits.to_string()
550    };
551
552    // If all zeros, force positive sign
553    if digits == "0" {
554        sign = Sign::Positive;
555    }
556
557    Ok(NumericValue {
558        sign,
559        digits,
560        scale,
561    })
562}
563
564/// Parsed numeric value for encoding
565#[derive(Debug, Clone)]
566struct ParsedNumeric {
567    /// Sign of the number
568    sign: Sign,
569    /// All digits without decimal point (e.g., "12345" for 123.45)
570    digits: Vec<u8>,
571    /// Position of decimal point from right (0 for integers, 2 for 2 decimal places)
572    decimal_places: usize,
573}
574
575/// Parse a numeric string into its components for encoding
576fn parse_numeric_value(value: &str) -> Result<ParsedNumeric> {
577    let trimmed = value.trim();
578    if trimmed.is_empty() {
579        return Err(Error::new(
580            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
581            "Empty numeric value",
582        ));
583    }
584
585    let mut chars = trimmed.chars().peekable();
586    let sign = if chars.peek() == Some(&'-') {
587        chars.next();
588        Sign::Negative
589    } else if chars.peek() == Some(&'+') {
590        chars.next();
591        Sign::Positive
592    } else {
593        Sign::Positive
594    };
595
596    let mut digits = Vec::new();
597    let mut found_decimal = false;
598    let mut decimal_places = 0;
599    let mut found_digit = false;
600
601    for ch in chars {
602        if ch.is_ascii_digit() {
603            digits.push(ch as u8 - b'0');
604            if found_decimal {
605                decimal_places += 1;
606            }
607            found_digit = true;
608        } else if ch == '.' {
609            if found_decimal {
610                return Err(Error::new(
611                    ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
612                    format!("Multiple decimal points in value: {value}"),
613                ));
614            }
615            found_decimal = true;
616        } else {
617            return Err(Error::new(
618                ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
619                format!("Invalid character '{ch}' in numeric value: {value}"),
620            ));
621        }
622    }
623
624    if !found_digit {
625        return Err(Error::new(
626            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
627            format!("No digits found in value: {value}"),
628        ));
629    }
630
631    Ok(ParsedNumeric {
632        sign,
633        digits,
634        decimal_places,
635    })
636}
637
638/// Encode a numeric value to an edited PIC string
639///
640/// # Errors
641/// Returns error if the value cannot be encoded to the pattern
642#[inline]
643#[allow(clippy::too_many_lines)]
644#[must_use = "Handle the Result or propagate the error"]
645pub fn encode_edited_numeric(
646    value: &str,
647    pattern: &[PicToken],
648    scale: u16,
649    _blank_when_zero: bool,
650) -> Result<String> {
651    // Parse the input value
652    let parsed = parse_numeric_value(value)?;
653
654    // All tokens are now supported in E3.7 (including Space)
655    // No unsupported token check needed
656
657    // Check if value is all zeros (force positive sign)
658    let is_zero = parsed.digits.iter().all(|&d| d == 0);
659    let effective_sign = if is_zero { Sign::Positive } else { parsed.sign };
660
661    // Count numeric positions and decimal point in pattern
662    let mut has_decimal = false;
663    for token in pattern {
664        if *token == PicToken::DecimalPoint {
665            has_decimal = true;
666        }
667    }
668
669    // Calculate expected decimal places from pattern
670    let _pattern_decimal_places = if has_decimal {
671        // Count numeric positions after decimal point
672        let mut after_decimal = 0;
673        let mut found = false;
674        for token in pattern {
675            if *token == PicToken::DecimalPoint {
676                found = true;
677            } else if found
678                && matches!(
679                    token,
680                    PicToken::Digit | PicToken::ZeroSuppress | PicToken::ZeroInsert
681                )
682            {
683                after_decimal += 1;
684            }
685        }
686        after_decimal
687    } else {
688        0
689    };
690
691    // Adjust digits to match pattern scale
692    let scale = scale as usize;
693    let mut adjusted_digits = parsed.digits.clone();
694
695    // Pad or truncate to match scale
696    if scale > parsed.decimal_places {
697        // Need to add trailing zeros
698        let to_add = scale - parsed.decimal_places;
699        adjusted_digits.extend(std::iter::repeat_n(0, to_add));
700    } else if scale < parsed.decimal_places {
701        // Need to truncate (round down for now)
702        let to_remove = parsed.decimal_places - scale;
703        for _ in 0..to_remove {
704            adjusted_digits.pop();
705        }
706    }
707
708    // Calculate integer and fractional parts
709    let decimal_places = scale;
710    let total_digits = adjusted_digits.len();
711    let int_digits = total_digits.saturating_sub(decimal_places);
712
713    // Count integer and fractional positions in pattern
714    let mut int_positions = 0;
715    let mut frac_positions = 0;
716    let mut after_decimal = false;
717    for token in pattern {
718        match token {
719            PicToken::Digit
720            | PicToken::ZeroSuppress
721            | PicToken::ZeroInsert
722            | PicToken::AsteriskFill => {
723                if after_decimal {
724                    frac_positions += 1;
725                } else {
726                    int_positions += 1;
727                }
728            }
729            PicToken::DecimalPoint => {
730                after_decimal = true;
731            }
732            _ => {}
733        }
734    }
735
736    // Check if value fits in pattern
737    if int_digits > int_positions || decimal_places > frac_positions {
738        return Err(Error::new(
739            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
740            format!(
741                "Value too long for pattern (pattern has {int_positions} integer positions, value has {int_digits} digits)"
742            ),
743        ));
744    }
745
746    // Calculate output length (CR and DB are 2 characters each, others are 1)
747    let output_len: usize = pattern
748        .iter()
749        .map(|token| match token {
750            PicToken::Credit | PicToken::Debit => 2,
751            _ => 1,
752        })
753        .sum();
754
755    // Build output string by filling from right to left
756    let mut result: Vec<char> = vec![' '; output_len];
757    let mut int_digit_idx = int_digits; // Start from the end
758    let mut frac_digit_idx = decimal_places; // Start from the end
759
760    // Fill from right to left
761    // We need to track character position separately from token index
762    let mut char_pos = output_len;
763    for (token_idx, token) in pattern.iter().enumerate().rev() {
764        // Determine how many characters this token occupies
765        let token_width = match token {
766            PicToken::Credit | PicToken::Debit => 2,
767            _ => 1,
768        };
769        char_pos -= token_width;
770
771        match token {
772            PicToken::Digit => {
773                let digit = {
774                    // Check if this position is before or after decimal
775                    let is_after_decimal = pattern[..token_idx].contains(&PicToken::DecimalPoint);
776                    if is_after_decimal && frac_digit_idx > 0 {
777                        frac_digit_idx -= 1;
778                        char::from_digit(
779                            u32::from(adjusted_digits[int_digits + frac_digit_idx]),
780                            10,
781                        )
782                        .unwrap_or('0')
783                    } else if !is_after_decimal && int_digit_idx > 0 {
784                        int_digit_idx -= 1;
785                        char::from_digit(u32::from(adjusted_digits[int_digit_idx]), 10)
786                            .unwrap_or('0')
787                    } else {
788                        '0'
789                    }
790                };
791                result[char_pos] = digit;
792            }
793            PicToken::ZeroSuppress => {
794                let is_after_decimal = pattern[..token_idx].contains(&PicToken::DecimalPoint);
795                if is_after_decimal && frac_digit_idx > 0 {
796                    frac_digit_idx -= 1;
797                    let d = adjusted_digits[int_digits + frac_digit_idx];
798                    result[char_pos] = char::from_digit(u32::from(d), 10).unwrap_or('0');
799                } else if !is_after_decimal && int_digit_idx > 0 {
800                    int_digit_idx -= 1;
801                    let d = adjusted_digits[int_digit_idx];
802                    result[char_pos] = char::from_digit(u32::from(d), 10).unwrap_or('0');
803                } else {
804                    result[char_pos] = ' ';
805                }
806            }
807            PicToken::ZeroInsert => {
808                let is_after_decimal = pattern[..token_idx].contains(&PicToken::DecimalPoint);
809                if is_after_decimal && frac_digit_idx > 0 {
810                    frac_digit_idx -= 1;
811                    let d = adjusted_digits[int_digits + frac_digit_idx];
812                    result[char_pos] = char::from_digit(u32::from(d), 10).unwrap_or('0');
813                } else if !is_after_decimal && int_digit_idx > 0 {
814                    int_digit_idx -= 1;
815                    let d = adjusted_digits[int_digit_idx];
816                    result[char_pos] = char::from_digit(u32::from(d), 10).unwrap_or('0');
817                } else {
818                    result[char_pos] = '0';
819                }
820            }
821            PicToken::AsteriskFill => {
822                let is_after_decimal = pattern[..token_idx].contains(&PicToken::DecimalPoint);
823                if is_after_decimal && frac_digit_idx > 0 {
824                    frac_digit_idx -= 1;
825                    let d = adjusted_digits[int_digits + frac_digit_idx];
826                    result[char_pos] = char::from_digit(u32::from(d), 10).unwrap_or('0');
827                } else if !is_after_decimal && int_digit_idx > 0 {
828                    int_digit_idx -= 1;
829                    let d = adjusted_digits[int_digit_idx];
830                    result[char_pos] = char::from_digit(u32::from(d), 10).unwrap_or('0');
831                } else {
832                    result[char_pos] = '*';
833                }
834            }
835            PicToken::DecimalPoint => {
836                result[char_pos] = '.';
837            }
838            PicToken::Comma => {
839                // Commas are always displayed, but become spaces during zero suppression
840                // Check if there are any significant digits to the right (already filled)
841                // or if the value is not zero
842                let has_significant_right = result[char_pos + 1..]
843                    .iter()
844                    .any(|&ch| ch != ' ' && ch != '0' && ch != ',' && ch != '.');
845                result[char_pos] = if !is_zero || has_significant_right {
846                    ','
847                } else {
848                    ' '
849                };
850            }
851            PicToken::Slash => {
852                // Slashes are always displayed (date format use case)
853                result[char_pos] = '/';
854            }
855            PicToken::Currency => {
856                // Currency symbol ($) is always displayed at its pattern position
857                result[char_pos] = '$';
858            }
859            PicToken::LeadingPlus | PicToken::TrailingPlus => {
860                result[char_pos] = match effective_sign {
861                    Sign::Positive => '+',
862                    Sign::Negative => '-',
863                };
864            }
865            PicToken::LeadingMinus | PicToken::TrailingMinus => {
866                result[char_pos] = match effective_sign {
867                    Sign::Positive => ' ',
868                    Sign::Negative => '-',
869                };
870            }
871            PicToken::Credit => {
872                // CR occupies 2 characters: "CR" for negative, "  " for positive
873                match effective_sign {
874                    Sign::Positive => {
875                        result[char_pos] = ' ';
876                        result[char_pos + 1] = ' ';
877                    }
878                    Sign::Negative => {
879                        result[char_pos] = 'C';
880                        result[char_pos + 1] = 'R';
881                    }
882                }
883            }
884            PicToken::Debit => {
885                // DB occupies 2 characters: "DB" for negative, "  " for positive
886                match effective_sign {
887                    Sign::Positive => {
888                        result[char_pos] = ' ';
889                        result[char_pos + 1] = ' ';
890                    }
891                    Sign::Negative => {
892                        result[char_pos] = 'D';
893                        result[char_pos + 1] = 'B';
894                    }
895                }
896            }
897            PicToken::Space => {
898                // B token always inserts a literal space character
899                result[char_pos] = ' ';
900            }
901        }
902    }
903
904    Ok(result.into_iter().collect())
905}
906
907#[cfg(test)]
908#[allow(clippy::expect_used, clippy::unwrap_used)]
909mod tests {
910    use super::*;
911
912    #[test]
913    fn test_tokenize_simple_z() {
914        let tokens = tokenize_edited_pic("ZZZ9").unwrap();
915        assert_eq!(
916            tokens,
917            vec![
918                PicToken::ZeroSuppress,
919                PicToken::ZeroSuppress,
920                PicToken::ZeroSuppress,
921                PicToken::Digit
922            ]
923        );
924    }
925
926    #[test]
927    fn test_tokenize_with_decimal() {
928        let tokens = tokenize_edited_pic("ZZZ9.99").unwrap();
929        assert_eq!(
930            tokens,
931            vec![
932                PicToken::ZeroSuppress,
933                PicToken::ZeroSuppress,
934                PicToken::ZeroSuppress,
935                PicToken::Digit,
936                PicToken::DecimalPoint,
937                PicToken::Digit,
938                PicToken::Digit
939            ]
940        );
941    }
942
943    #[test]
944    fn test_tokenize_currency() {
945        let tokens = tokenize_edited_pic("$ZZ,ZZZ.99").unwrap();
946        assert_eq!(tokens[0], PicToken::Currency);
947        assert!(tokens.contains(&PicToken::Comma));
948        assert!(tokens.contains(&PicToken::DecimalPoint));
949    }
950
951    #[test]
952    fn test_decode_simple() {
953        let pattern = tokenize_edited_pic("ZZZ9").unwrap();
954        let result = decode_edited_numeric("  12", &pattern, 0, false).unwrap();
955        assert_eq!(result.sign, Sign::Positive);
956        assert_eq!(result.digits, "12");
957        assert_eq!(result.to_decimal_string(), "12");
958    }
959
960    #[test]
961    fn test_decode_with_decimal() {
962        let pattern = tokenize_edited_pic("ZZZ9.99").unwrap();
963        let result = decode_edited_numeric("  12.34", &pattern, 2, false).unwrap();
964        assert_eq!(result.sign, Sign::Positive);
965        assert_eq!(result.digits, "1234");
966        assert_eq!(result.scale, 2);
967        assert_eq!(result.to_decimal_string(), "12.34");
968    }
969
970    #[test]
971    fn test_decode_blank_when_zero() {
972        let pattern = tokenize_edited_pic("ZZZ9").unwrap();
973        let result = decode_edited_numeric("    ", &pattern, 0, true).unwrap();
974        assert_eq!(result.to_decimal_string(), "0");
975    }
976
977    #[test]
978    fn test_decode_with_currency() {
979        let pattern = tokenize_edited_pic("$ZZZ.99").unwrap();
980        let result = decode_edited_numeric("$ 12.34", &pattern, 2, false).unwrap();
981        assert_eq!(result.to_decimal_string(), "12.34");
982    }
983
984    #[test]
985    fn test_decode_trailing_cr() {
986        let pattern = tokenize_edited_pic("ZZZ9CR").unwrap();
987        let result = decode_edited_numeric("  12CR", &pattern, 0, false).unwrap();
988        assert_eq!(result.sign, Sign::Negative);
989        assert_eq!(result.to_decimal_string(), "-12");
990    }
991
992    #[test]
993    fn test_decode_trailing_db() {
994        let pattern = tokenize_edited_pic("ZZZ9DB").unwrap();
995        let result = decode_edited_numeric("  12DB", &pattern, 0, false).unwrap();
996        assert_eq!(result.sign, Sign::Negative);
997        assert_eq!(result.to_decimal_string(), "-12");
998    }
999
1000    // ===== E3.1 Encode Tests =====
1001
1002    #[test]
1003    fn test_encode_basic_digits() {
1004        let pattern = tokenize_edited_pic("9999").unwrap();
1005        let result = encode_edited_numeric("1234", &pattern, 0, false).unwrap();
1006        assert_eq!(result, "1234");
1007    }
1008
1009    #[test]
1010    fn test_encode_zero_with_zero_insert() {
1011        let pattern = tokenize_edited_pic("9999").unwrap();
1012        let result = encode_edited_numeric("0", &pattern, 0, false).unwrap();
1013        assert_eq!(result, "0000");
1014    }
1015
1016    #[test]
1017    fn test_encode_zero_suppression() {
1018        let pattern = tokenize_edited_pic("ZZZ9").unwrap();
1019        let result = encode_edited_numeric("123", &pattern, 0, false).unwrap();
1020        assert_eq!(result, " 123");
1021    }
1022
1023    #[test]
1024    fn test_encode_zero_suppression_zero() {
1025        let pattern = tokenize_edited_pic("ZZZ9").unwrap();
1026        let result = encode_edited_numeric("0", &pattern, 0, false).unwrap();
1027        assert_eq!(result, "   0");
1028    }
1029
1030    #[test]
1031    fn test_encode_zero_suppression_single_digit() {
1032        let pattern = tokenize_edited_pic("ZZZ9").unwrap();
1033        let result = encode_edited_numeric("1", &pattern, 0, false).unwrap();
1034        assert_eq!(result, "   1");
1035    }
1036
1037    #[test]
1038    fn test_encode_zero_insert() {
1039        let pattern = tokenize_edited_pic("0009").unwrap();
1040        let result = encode_edited_numeric("123", &pattern, 0, false).unwrap();
1041        assert_eq!(result, "0123");
1042    }
1043
1044    #[test]
1045    fn test_encode_zero_insert_all_zeros() {
1046        let pattern = tokenize_edited_pic("0009").unwrap();
1047        let result = encode_edited_numeric("0", &pattern, 0, false).unwrap();
1048        assert_eq!(result, "0000");
1049    }
1050
1051    #[test]
1052    fn test_encode_decimal_point() {
1053        let pattern = tokenize_edited_pic("99.99").unwrap();
1054        let result = encode_edited_numeric("12.34", &pattern, 2, false).unwrap();
1055        assert_eq!(result, "12.34");
1056    }
1057
1058    #[test]
1059    fn test_encode_zero_decimal() {
1060        let pattern = tokenize_edited_pic("99.99").unwrap();
1061        let result = encode_edited_numeric("0.00", &pattern, 2, false).unwrap();
1062        assert_eq!(result, "00.00");
1063    }
1064
1065    #[test]
1066    fn test_encode_leading_plus_positive() {
1067        let pattern = tokenize_edited_pic("+999").unwrap();
1068        let result = encode_edited_numeric("123", &pattern, 0, false).unwrap();
1069        assert_eq!(result, "+123");
1070    }
1071
1072    #[test]
1073    fn test_encode_leading_plus_negative() {
1074        let pattern = tokenize_edited_pic("+999").unwrap();
1075        let result = encode_edited_numeric("-123", &pattern, 0, false).unwrap();
1076        assert_eq!(result, "-123");
1077    }
1078
1079    #[test]
1080    fn test_encode_leading_minus_positive() {
1081        let pattern = tokenize_edited_pic("-999").unwrap();
1082        let result = encode_edited_numeric("123", &pattern, 0, false).unwrap();
1083        assert_eq!(result, " 123");
1084    }
1085
1086    #[test]
1087    fn test_encode_leading_minus_negative() {
1088        let pattern = tokenize_edited_pic("-999").unwrap();
1089        let result = encode_edited_numeric("-123", &pattern, 0, false).unwrap();
1090        assert_eq!(result, "-123");
1091    }
1092
1093    #[test]
1094    fn test_encode_leading_plus_with_decimal() {
1095        let pattern = tokenize_edited_pic("+99.99").unwrap();
1096        let result = encode_edited_numeric("12.34", &pattern, 2, false).unwrap();
1097        assert_eq!(result, "+12.34");
1098    }
1099
1100    #[test]
1101    fn test_encode_leading_minus_with_decimal() {
1102        let pattern = tokenize_edited_pic("-99.99").unwrap();
1103        let result = encode_edited_numeric("-12.34", &pattern, 2, false).unwrap();
1104        assert_eq!(result, "-12.34");
1105    }
1106
1107    #[test]
1108    fn test_encode_negative_zero_forces_positive() {
1109        let pattern = tokenize_edited_pic("-999").unwrap();
1110        let result = encode_edited_numeric("-0", &pattern, 0, false).unwrap();
1111        assert_eq!(result, " 000");
1112    }
1113
1114    #[test]
1115    fn test_encode_value_too_long() {
1116        let pattern = tokenize_edited_pic("999").unwrap();
1117        let result = encode_edited_numeric("1234", &pattern, 0, false);
1118        assert!(result.is_err());
1119        assert!(matches!(
1120            result.unwrap_err().code,
1121            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT
1122        ));
1123    }
1124
1125    // ===== E3.7 Space Insertion Tests =====
1126
1127    #[test]
1128    fn test_encode_space_insertion_simple() {
1129        let pattern = tokenize_edited_pic("999B999").unwrap();
1130        let result = encode_edited_numeric("123456", &pattern, 0, false).unwrap();
1131        assert_eq!(result, "123 456");
1132    }
1133
1134    #[test]
1135    fn test_encode_space_insertion_multiple() {
1136        let pattern = tokenize_edited_pic("9B9B9").unwrap();
1137        let result = encode_edited_numeric("123", &pattern, 0, false).unwrap();
1138        assert_eq!(result, "1 2 3");
1139    }
1140
1141    #[test]
1142    fn test_encode_space_with_zero_suppress() {
1143        let pattern = tokenize_edited_pic("ZZZB999").unwrap();
1144        let result = encode_edited_numeric("123456", &pattern, 0, false).unwrap();
1145        assert_eq!(result, "123 456");
1146    }
1147
1148    #[test]
1149    fn test_encode_space_with_decimal() {
1150        let pattern = tokenize_edited_pic("999B999.99").unwrap();
1151        let result = encode_edited_numeric("123456.78", &pattern, 2, false).unwrap();
1152        assert_eq!(result, "123 456.78");
1153    }
1154
1155    #[test]
1156    fn test_encode_space_multiple_repetition() {
1157        let pattern = tokenize_edited_pic("99B(3)99").unwrap();
1158        let result = encode_edited_numeric("1234", &pattern, 0, false).unwrap();
1159        assert_eq!(result, "12   34");
1160    }
1161
1162    #[test]
1163    fn test_encode_space_with_currency() {
1164        let pattern = tokenize_edited_pic("$999B999.99").unwrap();
1165        let result = encode_edited_numeric("123456.78", &pattern, 2, false).unwrap();
1166        assert_eq!(result, "$123 456.78");
1167    }
1168
1169    #[test]
1170    fn test_encode_space_with_sign() {
1171        let pattern = tokenize_edited_pic("+999B999").unwrap();
1172        let result = encode_edited_numeric("123456", &pattern, 0, false).unwrap();
1173        assert_eq!(result, "+123 456");
1174    }
1175
1176    #[test]
1177    fn test_encode_empty_value() {
1178        let pattern = tokenize_edited_pic("999").unwrap();
1179        let result = encode_edited_numeric("", &pattern, 0, false);
1180        assert!(result.is_err());
1181    }
1182
1183    #[test]
1184    fn test_encode_invalid_character() {
1185        let pattern = tokenize_edited_pic("999").unwrap();
1186        let result = encode_edited_numeric("12a", &pattern, 0, false);
1187        assert!(result.is_err());
1188    }
1189}