Skip to main content

copybook_codec/
options.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Configuration option types owned by the codec runtime.
4//!
5//! This crate defines [`DecodeOptions`], [`EncodeOptions`], [`RecordFormat`],
6//! [`JsonNumberMode`], [`RawMode`], and [`FloatFormat`] — the common configuration
7//! surface used by the codec and CLI layers. The former `copybook-options`
8//! package forwards to this module for compatibility.
9#![allow(clippy::missing_inline_in_public_items)]
10
11mod parse_error;
12pub use parse_error::{CodecOptionKind, ParseCodecOptionError};
13
14// Re-export from copybook-charset for public API
15/// Zoned decimal encoding format (ASCII, EBCDIC, or auto-detect).
16pub use crate::numeric::ZonedEncodingFormat;
17/// Codepage identifier for EBCDIC/ASCII character encoding.
18pub use copybook_charset::Codepage;
19/// Policy for handling unmappable characters during codepage conversion.
20pub use copybook_charset::UnmappablePolicy;
21use serde::{Deserialize, Serialize};
22use std::{fmt, str::FromStr};
23
24const DEFAULT_RECORD_FORMAT: RecordFormat = RecordFormat::Fixed;
25const DEFAULT_CODEPAGE: Codepage = Codepage::CP037;
26const DEFAULT_JSON_NUMBER_MODE: JsonNumberMode = JsonNumberMode::Lossless;
27const DEFAULT_UNMAPPABLE_POLICY: UnmappablePolicy = UnmappablePolicy::Error;
28const DEFAULT_THREAD_COUNT: usize = 1;
29const DEFAULT_ZONED_ENCODING: ZonedEncodingFormat = ZonedEncodingFormat::Auto;
30const DEFAULT_FLOAT_FORMAT: FloatFormat = FloatFormat::IeeeBigEndian;
31
32macro_rules! impl_common_option_builders {
33    () => {
34        /// Set the record format.
35        #[must_use]
36        #[inline]
37        pub fn with_format(mut self, format: RecordFormat) -> Self {
38            self.format = format;
39            self
40        }
41
42        /// Set the codepage.
43        #[must_use]
44        #[inline]
45        pub fn with_codepage(mut self, codepage: Codepage) -> Self {
46            self.codepage = codepage;
47            self
48        }
49
50        /// Enable or disable strict mode.
51        #[must_use]
52        #[inline]
53        pub fn with_strict_mode(mut self, strict_mode: bool) -> Self {
54            self.strict_mode = strict_mode;
55            self
56        }
57
58        /// Set the maximum number of errors before stopping.
59        #[must_use]
60        #[inline]
61        pub fn with_max_errors(mut self, max_errors: Option<u64>) -> Self {
62            self.max_errors = max_errors;
63            self
64        }
65
66        /// Set the number of threads for parallel processing.
67        #[must_use]
68        #[inline]
69        pub fn with_threads(mut self, threads: usize) -> Self {
70            self.threads = threads;
71            self
72        }
73
74        /// Set the JSON number mode.
75        #[must_use]
76        #[inline]
77        pub fn with_json_number_mode(mut self, mode: JsonNumberMode) -> Self {
78            self.json_number_mode = mode;
79            self
80        }
81
82        /// Set the preferred zoned decimal encoding format.
83        #[must_use]
84        #[inline]
85        pub fn with_preferred_zoned_encoding(
86            mut self,
87            preferred_zoned_encoding: ZonedEncodingFormat,
88        ) -> Self {
89            self.preferred_zoned_encoding = preferred_zoned_encoding;
90            self
91        }
92
93        /// Set floating-point representation for COMP-1/COMP-2 fields.
94        #[must_use]
95        #[inline]
96        pub fn with_float_format(mut self, float_format: FloatFormat) -> Self {
97            self.float_format = float_format;
98            self
99        }
100    };
101}
102
103/// Floating-point binary format for COMP-1/COMP-2 fields.
104///
105/// Copybooks define field usage but not the compiler's concrete floating-point
106/// representation. This option makes the decode/encode interpretation explicit.
107///
108/// # Examples
109///
110/// ```
111/// use copybook_codec::options::FloatFormat;
112///
113/// let fmt = FloatFormat::default();
114/// assert_eq!(fmt, FloatFormat::IeeeBigEndian);
115/// assert_eq!(format!("{fmt}"), "ieee-be");
116/// ```
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
118pub enum FloatFormat {
119    /// IEEE-754 big-endian binary format.
120    #[default]
121    IeeeBigEndian,
122    /// IBM hexadecimal floating-point format.
123    IbmHex,
124}
125
126impl FromStr for FloatFormat {
127    type Err = ParseCodecOptionError;
128
129    fn from_str(input: &str) -> Result<Self, Self::Err> {
130        match input.to_ascii_lowercase().as_str() {
131            "ieee-be" | "ieee" | "ieee-big-endian" => Ok(Self::IeeeBigEndian),
132            "ibm-hex" | "ibm" => Ok(Self::IbmHex),
133            _ => Err(ParseCodecOptionError::new(
134                CodecOptionKind::FloatFormat,
135                input,
136            )),
137        }
138    }
139}
140
141/// Options for decoding operations
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[allow(clippy::struct_excessive_bools)] // Many boolean options are needed for decode configuration
144pub struct DecodeOptions {
145    /// Record format
146    pub format: RecordFormat,
147    /// Character encoding
148    pub codepage: Codepage,
149    /// JSON number representation
150    pub json_number_mode: JsonNumberMode,
151    /// Whether to emit FILLER fields
152    pub emit_filler: bool,
153    /// Whether to emit metadata
154    pub emit_meta: bool,
155    /// Raw data capture mode
156    pub emit_raw: RawMode,
157    /// Error handling mode
158    pub strict_mode: bool,
159    /// Maximum errors before stopping
160    pub max_errors: Option<u64>,
161    /// Policy for unmappable characters
162    pub on_decode_unmappable: UnmappablePolicy,
163    /// Number of threads for parallel processing
164    pub threads: usize,
165    /// Enable zoned decimal encoding preservation for binary round-trip consistency
166    ///
167    /// When enabled, the decoder captures the original encoding format (ASCII vs EBCDIC)
168    /// and includes it in metadata for use during re-encoding to maintain byte-level
169    /// fidelity in encode/decode cycles.
170    pub preserve_zoned_encoding: bool,
171    /// Preferred encoding format when auto-detection is ambiguous
172    ///
173    /// Used as fallback when `ZonedEncodingFormat::Auto` cannot determine the format
174    /// from the data (e.g., all-zero fields, mixed encodings).
175    pub preferred_zoned_encoding: ZonedEncodingFormat,
176    /// Floating-point representation for COMP-1/COMP-2 fields.
177    #[serde(default)]
178    pub float_format: FloatFormat,
179}
180
181/// Options for encoding operations
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[allow(clippy::struct_excessive_bools)]
184pub struct EncodeOptions {
185    /// Record format
186    pub format: RecordFormat,
187    /// Character encoding
188    pub codepage: Codepage,
189    /// Fallback zoned decimal encoding format when no override or metadata applies
190    pub preferred_zoned_encoding: ZonedEncodingFormat,
191    /// Whether to use raw data when available
192    pub use_raw: bool,
193    /// BLANK WHEN ZERO encoding policy
194    pub bwz_encode: bool,
195    /// Error handling mode
196    pub strict_mode: bool,
197    /// Maximum errors before stopping
198    pub max_errors: Option<u64>,
199    /// Number of threads for parallel processing
200    pub threads: usize,
201    /// Whether to coerce non-string JSON numbers to strings before encoding
202    pub coerce_numbers: bool,
203    /// Policy for unmappable characters during encoding
204    pub on_encode_unmappable: UnmappablePolicy,
205    /// JSON number representation mode (used when round-tripping)
206    pub json_number_mode: JsonNumberMode,
207    /// Explicit zoned decimal encoding format override
208    ///
209    /// When specified, forces all zoned decimal fields to use this encoding format,
210    /// overriding any preserved format from decode operations. This provides the
211    /// highest precedence in the format selection hierarchy:
212    /// 1. Explicit override (this field)
213    /// 2. Preserved format from decode metadata
214    /// 3. EBCDIC default for mainframe compatibility
215    pub zoned_encoding_override: Option<ZonedEncodingFormat>,
216    /// Floating-point representation for COMP-1/COMP-2 fields.
217    #[serde(default)]
218    pub float_format: FloatFormat,
219}
220
221/// Record format specification
222///
223/// Controls whether records have a fixed byte length (LRECL) or use
224/// variable-length RDW (Record Descriptor Word) framing.
225///
226/// # Examples
227///
228/// ```
229/// use copybook_codec::options::RecordFormat;
230///
231/// let fmt = RecordFormat::Fixed;
232/// assert!(fmt.is_fixed());
233/// assert!(!fmt.is_variable());
234/// assert_eq!(fmt.description(), "Fixed-length records");
235/// ```
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237pub enum RecordFormat {
238    /// Fixed-length records
239    Fixed,
240    /// Variable-length records with RDW
241    RDW,
242    /// Variable-blocked records: RDW records grouped in BDW blocks (beta)
243    Vb,
244}
245
246impl FromStr for RecordFormat {
247    type Err = ParseCodecOptionError;
248
249    fn from_str(input: &str) -> Result<Self, Self::Err> {
250        match input.to_ascii_lowercase().as_str() {
251            "fixed" => Ok(Self::Fixed),
252            "rdw" => Ok(Self::RDW),
253            "vb" => Ok(Self::Vb),
254            _ => Err(ParseCodecOptionError::new(
255                CodecOptionKind::RecordFormat,
256                input,
257            )),
258        }
259    }
260}
261
262impl RecordFormat {
263    /// Check if this is a fixed-length record format
264    #[must_use]
265    pub const fn is_fixed(self) -> bool {
266        matches!(self, Self::Fixed)
267    }
268
269    /// Check if this is a variable-length record format
270    #[must_use]
271    pub const fn is_variable(self) -> bool {
272        matches!(self, Self::RDW | Self::Vb)
273    }
274
275    /// Get a human-readable description of the format
276    #[must_use]
277    pub const fn description(self) -> &'static str {
278        match self {
279            Self::Fixed => "Fixed-length records",
280            Self::RDW => "Variable-length records with Record Descriptor Word",
281            Self::Vb => "Variable-blocked records with BDW blocks (beta)",
282        }
283    }
284}
285
286/// JSON number representation mode
287///
288/// Controls how COBOL numeric fields are represented in JSON output.
289/// `Lossless` preserves exact decimal precision as strings; `Native` uses
290/// JSON number types where the value fits without precision loss.
291///
292/// # Examples
293///
294/// ```
295/// use copybook_codec::options::JsonNumberMode;
296///
297/// let mode = JsonNumberMode::Lossless;
298/// assert!(mode.is_lossless());
299/// assert_eq!(mode.description(), "Lossless string representation for decimals");
300/// ```
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302pub enum JsonNumberMode {
303    /// Lossless string representation for decimals
304    Lossless,
305    /// Native JSON numbers where possible
306    Native,
307}
308
309impl FromStr for JsonNumberMode {
310    type Err = ParseCodecOptionError;
311
312    fn from_str(input: &str) -> Result<Self, Self::Err> {
313        match input.to_ascii_lowercase().as_str() {
314            "lossless" => Ok(Self::Lossless),
315            "native" => Ok(Self::Native),
316            _ => Err(ParseCodecOptionError::new(
317                CodecOptionKind::JsonNumberMode,
318                input,
319            )),
320        }
321    }
322}
323
324impl JsonNumberMode {
325    /// Check if this mode uses lossless string representation
326    #[must_use]
327    #[inline]
328    pub const fn is_lossless(self) -> bool {
329        matches!(self, Self::Lossless)
330    }
331
332    /// Check if this mode uses native JSON numbers
333    #[must_use]
334    #[inline]
335    pub const fn is_native(self) -> bool {
336        matches!(self, Self::Native)
337    }
338
339    /// Get a human-readable description of the mode
340    #[must_use]
341    #[inline]
342    pub const fn description(self) -> &'static str {
343        match self {
344            Self::Lossless => "Lossless string representation for decimals",
345            Self::Native => "Native JSON numbers where possible",
346        }
347    }
348}
349
350/// Raw data capture mode
351///
352/// Controls whether and how binary record data is captured in the output:
353/// - `Off` — no raw data (default, lowest overhead)
354/// - `Record` — record payload in `raw_b64` and compatibility `__raw_b64`
355/// - `RecordRDW` — RDW header plus record payload in `raw_b64` and compatibility `__raw_b64`
356/// - `Field` — only per-field raw bytes in `<FIELD_NAME>_raw_b64`
357///
358/// Whole-record capture also emits `raw_capture` with `record` or `record+rdw`
359/// so RDW replay does not infer framing from byte contents.
360///
361/// # Examples
362///
363/// ```
364/// use copybook_codec::options::RawMode;
365///
366/// let mode = RawMode::Off;
367/// assert_eq!(mode, RawMode::Off);
368///
369/// let field_mode = RawMode::Field;
370/// assert_ne!(field_mode, RawMode::Off);
371/// ```
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373pub enum RawMode {
374    /// No raw data capture
375    Off,
376    /// Capture record-level raw data
377    Record,
378    /// Capture field-level raw data
379    Field,
380    /// Capture record and RDW header
381    RecordRDW,
382}
383
384impl FromStr for RawMode {
385    type Err = ParseCodecOptionError;
386
387    fn from_str(input: &str) -> Result<Self, Self::Err> {
388        match input.to_ascii_lowercase().as_str() {
389            "off" => Ok(Self::Off),
390            "record" => Ok(Self::Record),
391            "field" => Ok(Self::Field),
392            "record+rdw" => Ok(Self::RecordRDW),
393            _ => Err(ParseCodecOptionError::new(CodecOptionKind::RawMode, input)),
394        }
395    }
396}
397
398impl Default for DecodeOptions {
399    fn default() -> Self {
400        Self {
401            format: DEFAULT_RECORD_FORMAT,
402            codepage: DEFAULT_CODEPAGE,
403            json_number_mode: DEFAULT_JSON_NUMBER_MODE,
404            emit_filler: false,
405            emit_meta: false,
406            emit_raw: RawMode::Off,
407            strict_mode: false,
408            max_errors: None,
409            on_decode_unmappable: DEFAULT_UNMAPPABLE_POLICY,
410            threads: DEFAULT_THREAD_COUNT,
411            preserve_zoned_encoding: false,
412            preferred_zoned_encoding: DEFAULT_ZONED_ENCODING,
413            float_format: DEFAULT_FLOAT_FORMAT,
414        }
415    }
416}
417
418impl DecodeOptions {
419    /// Create new decode options with default values
420    ///
421    /// Returns options configured for:
422    /// - Fixed record format
423    /// - CP037 EBCDIC codepage
424    /// - Lossless JSON number mode
425    /// - Single-threaded processing
426    ///
427    /// Use the builder methods to customize:
428    ///
429    /// # Examples
430    ///
431    /// ```
432    /// use copybook_codec::options::{DecodeOptions, Codepage, JsonNumberMode, RecordFormat, RawMode};
433    ///
434    /// let opts = DecodeOptions::new()
435    ///     .with_codepage(Codepage::CP037)
436    ///     .with_format(RecordFormat::Fixed)
437    ///     .with_json_number_mode(JsonNumberMode::Lossless)
438    ///     .with_emit_meta(true)
439    ///     .with_threads(4);
440    ///
441    /// assert_eq!(opts.threads, 4);
442    /// assert!(opts.emit_meta);
443    /// ```
444    #[must_use]
445    #[inline]
446    pub fn new() -> Self {
447        Self::default()
448    }
449
450    impl_common_option_builders!();
451
452    /// Enable or disable FILLER field emission
453    #[must_use]
454    #[inline]
455    pub fn with_emit_filler(mut self, emit_filler: bool) -> Self {
456        self.emit_filler = emit_filler;
457        self
458    }
459
460    /// Enable or disable metadata emission
461    #[must_use]
462    #[inline]
463    pub fn with_emit_meta(mut self, emit_meta: bool) -> Self {
464        self.emit_meta = emit_meta;
465        self
466    }
467
468    /// Set the raw data capture mode
469    ///
470    /// Controls whether and how raw binary data is included in decode output:
471    /// - `RawMode::Off` — no raw data (default)
472    /// - `RawMode::Record` — record payload in `raw_b64` and compatibility `__raw_b64`
473    /// - `RawMode::RecordRDW` — RDW header plus payload in `raw_b64` and compatibility `__raw_b64`
474    /// - `RawMode::Field` — only per-field raw values in `<FIELD>_raw_b64`
475    ///
476    /// Whole-record capture also emits matching `raw_capture` provenance.
477    #[must_use]
478    #[inline]
479    pub fn with_emit_raw(mut self, emit_raw: RawMode) -> Self {
480        self.emit_raw = emit_raw;
481        self
482    }
483
484    /// Set the policy for unmappable characters
485    #[must_use]
486    #[inline]
487    pub fn with_unmappable_policy(mut self, policy: UnmappablePolicy) -> Self {
488        self.on_decode_unmappable = policy;
489        self
490    }
491
492    // === Zoned Decimal Encoding Configuration ===
493
494    /// Enable zoned decimal encoding preservation for round-trip fidelity
495    ///
496    /// When enabled, the decoder will detect and preserve the original encoding
497    /// format (ASCII vs EBCDIC) for use during subsequent encoding operations.
498    /// This ensures byte-level consistency in encode/decode cycles.
499    #[must_use]
500    #[inline]
501    pub fn with_preserve_zoned_encoding(mut self, preserve_zoned_encoding: bool) -> Self {
502        self.preserve_zoned_encoding = preserve_zoned_encoding;
503        self
504    }
505}
506
507impl Default for EncodeOptions {
508    fn default() -> Self {
509        Self {
510            format: DEFAULT_RECORD_FORMAT,
511            codepage: DEFAULT_CODEPAGE,
512            preferred_zoned_encoding: DEFAULT_ZONED_ENCODING,
513            use_raw: false,
514            bwz_encode: false,
515            strict_mode: false,
516            max_errors: None,
517            threads: DEFAULT_THREAD_COUNT,
518            coerce_numbers: false,
519            on_encode_unmappable: DEFAULT_UNMAPPABLE_POLICY,
520            json_number_mode: DEFAULT_JSON_NUMBER_MODE,
521            zoned_encoding_override: None,
522            float_format: DEFAULT_FLOAT_FORMAT,
523        }
524    }
525}
526
527impl EncodeOptions {
528    /// Create new encode options with default values
529    ///
530    /// Returns options configured for:
531    /// - Fixed record format
532    /// - CP037 EBCDIC codepage
533    /// - Single-threaded processing
534    /// - BLANK WHEN ZERO disabled
535    ///
536    /// Use the builder methods to customize:
537    ///
538    /// # Examples
539    ///
540    /// ```
541    /// use copybook_codec::options::{EncodeOptions, Codepage, RecordFormat};
542    ///
543    /// let opts = EncodeOptions::new()
544    ///     .with_codepage(Codepage::CP037)
545    ///     .with_format(RecordFormat::Fixed)
546    ///     .with_bwz_encode(true)
547    ///     .with_coerce_numbers(true)
548    ///     .with_threads(4);
549    ///
550    /// assert_eq!(opts.threads, 4);
551    /// assert!(opts.bwz_encode);
552    /// assert!(opts.coerce_numbers);
553    /// ```
554    #[must_use]
555    #[inline]
556    pub fn new() -> Self {
557        Self::default()
558    }
559
560    impl_common_option_builders!();
561
562    /// Enable or disable raw data usage
563    #[must_use]
564    #[inline]
565    pub fn with_use_raw(mut self, use_raw: bool) -> Self {
566        self.use_raw = use_raw;
567        self
568    }
569
570    /// Enable or disable BLANK WHEN ZERO encoding
571    #[must_use]
572    #[inline]
573    pub fn with_bwz_encode(mut self, bwz_encode: bool) -> Self {
574        self.bwz_encode = bwz_encode;
575        self
576    }
577
578    /// Enable or disable number coercion
579    #[must_use]
580    #[inline]
581    pub fn with_coerce_numbers(mut self, coerce_numbers: bool) -> Self {
582        self.coerce_numbers = coerce_numbers;
583        self
584    }
585
586    /// Set the policy for unmappable characters during encoding
587    #[must_use]
588    #[inline]
589    pub fn with_unmappable_policy(mut self, policy: UnmappablePolicy) -> Self {
590        self.on_encode_unmappable = policy;
591        self
592    }
593
594    /// Set explicit zoned decimal encoding format override
595    ///
596    /// Forces all zoned decimal fields to use the specified encoding format,
597    /// overriding any preserved format from decode operations. Use `None` to
598    /// disable override and respect preserved formats.
599    #[must_use]
600    #[inline]
601    pub fn with_zoned_encoding_override(
602        mut self,
603        zoned_encoding_override: Option<ZonedEncodingFormat>,
604    ) -> Self {
605        self.zoned_encoding_override = zoned_encoding_override;
606        self
607    }
608
609    /// Convenience method to set explicit zoned encoding format
610    ///
611    /// Equivalent to `with_zoned_encoding_override(Some(format))`.
612    #[must_use]
613    #[inline]
614    pub fn with_zoned_encoding_format(mut self, format: ZonedEncodingFormat) -> Self {
615        self.zoned_encoding_override = Some(format);
616        self
617    }
618}
619impl fmt::Display for RecordFormat {
620    #[inline]
621    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
622        match self {
623            Self::Fixed => write!(f, "fixed"),
624            Self::RDW => write!(f, "rdw"),
625            Self::Vb => write!(f, "vb"),
626        }
627    }
628}
629
630impl fmt::Display for JsonNumberMode {
631    #[inline]
632    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
633        match self {
634            Self::Lossless => write!(f, "lossless"),
635            Self::Native => write!(f, "native"),
636        }
637    }
638}
639
640impl fmt::Display for RawMode {
641    #[inline]
642    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
643        match self {
644            Self::Off => write!(f, "off"),
645            Self::Record => write!(f, "record"),
646            Self::Field => write!(f, "field"),
647            Self::RecordRDW => write!(f, "record+rdw"),
648        }
649    }
650}
651
652impl fmt::Display for FloatFormat {
653    #[inline]
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        match self {
656            Self::IeeeBigEndian => write!(f, "ieee-be"),
657            Self::IbmHex => write!(f, "ibm-hex"),
658        }
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn test_zoned_encoding_format_is_ascii() {
668        assert!(ZonedEncodingFormat::Ascii.is_ascii());
669        assert!(!ZonedEncodingFormat::Ebcdic.is_ascii());
670        assert!(!ZonedEncodingFormat::Auto.is_ascii());
671    }
672
673    #[test]
674    fn test_zoned_encoding_format_is_ebcdic() {
675        assert!(!ZonedEncodingFormat::Ascii.is_ebcdic());
676        assert!(ZonedEncodingFormat::Ebcdic.is_ebcdic());
677        assert!(!ZonedEncodingFormat::Auto.is_ebcdic());
678    }
679
680    #[test]
681    fn test_zoned_encoding_format_is_auto() {
682        assert!(!ZonedEncodingFormat::Ascii.is_auto());
683        assert!(!ZonedEncodingFormat::Ebcdic.is_auto());
684        assert!(ZonedEncodingFormat::Auto.is_auto());
685    }
686
687    #[test]
688    fn test_zoned_encoding_format_description() {
689        assert_eq!(
690            ZonedEncodingFormat::Ascii.description(),
691            "ASCII digit zones (0x30-0x39)"
692        );
693        assert_eq!(
694            ZonedEncodingFormat::Ebcdic.description(),
695            "EBCDIC digit zones (0xF0-0xF9)"
696        );
697        assert_eq!(
698            ZonedEncodingFormat::Auto.description(),
699            "Automatic detection based on zone nibbles"
700        );
701    }
702
703    #[test]
704    fn test_zoned_encoding_format_detect_from_byte() {
705        // ASCII zone nibble (0x30)
706        assert_eq!(
707            ZonedEncodingFormat::detect_from_byte(0x35),
708            Some(ZonedEncodingFormat::Ascii)
709        );
710        assert_eq!(
711            ZonedEncodingFormat::detect_from_byte(0x30),
712            Some(ZonedEncodingFormat::Ascii)
713        );
714        assert_eq!(
715            ZonedEncodingFormat::detect_from_byte(0x39),
716            Some(ZonedEncodingFormat::Ascii)
717        );
718
719        // EBCDIC zone nibble (0xF0)
720        assert_eq!(
721            ZonedEncodingFormat::detect_from_byte(0xF5),
722            Some(ZonedEncodingFormat::Ebcdic)
723        );
724        assert_eq!(
725            ZonedEncodingFormat::detect_from_byte(0xF0),
726            Some(ZonedEncodingFormat::Ebcdic)
727        );
728        assert_eq!(
729            ZonedEncodingFormat::detect_from_byte(0xF9),
730            Some(ZonedEncodingFormat::Ebcdic)
731        );
732
733        // Invalid zone nibbles (0x00, 0x50)
734        assert_eq!(ZonedEncodingFormat::detect_from_byte(0x00), None);
735        assert_eq!(ZonedEncodingFormat::detect_from_byte(0x50), None);
736        // Note: 0xFF matches EBCDIC_ZONE (0x0F), so it returns Some(Ebcdic)
737        assert_eq!(
738            ZonedEncodingFormat::detect_from_byte(0xFF),
739            Some(ZonedEncodingFormat::Ebcdic)
740        );
741    }
742
743    #[test]
744    fn test_zoned_encoding_format_display() {
745        assert_eq!(format!("{}", ZonedEncodingFormat::Ascii), "ascii");
746        assert_eq!(format!("{}", ZonedEncodingFormat::Ebcdic), "ebcdic");
747        assert_eq!(format!("{}", ZonedEncodingFormat::Auto), "auto");
748    }
749
750    #[test]
751    fn test_decode_options_default() {
752        let options = DecodeOptions::default();
753        assert_eq!(options.format, RecordFormat::Fixed);
754        assert_eq!(options.codepage, Codepage::CP037);
755        assert_eq!(options.json_number_mode, JsonNumberMode::Lossless);
756        assert!(!options.emit_filler);
757        assert!(!options.emit_meta);
758        assert_eq!(options.emit_raw, RawMode::Off);
759        assert!(!options.strict_mode);
760        assert!(options.max_errors.is_none());
761        assert_eq!(options.on_decode_unmappable, UnmappablePolicy::Error);
762        assert_eq!(options.threads, 1);
763        assert!(!options.preserve_zoned_encoding);
764        assert_eq!(options.preferred_zoned_encoding, ZonedEncodingFormat::Auto);
765        assert_eq!(options.float_format, FloatFormat::IeeeBigEndian);
766    }
767
768    #[test]
769    fn test_encode_options_default() {
770        let options = EncodeOptions::default();
771        assert_eq!(options.format, RecordFormat::Fixed);
772        assert_eq!(options.codepage, Codepage::CP037);
773        assert_eq!(options.preferred_zoned_encoding, ZonedEncodingFormat::Auto);
774        assert!(!options.use_raw);
775        assert!(!options.bwz_encode);
776        assert!(!options.strict_mode);
777        assert_eq!(options.on_encode_unmappable, UnmappablePolicy::Error);
778        assert_eq!(options.json_number_mode, JsonNumberMode::Lossless);
779        assert_eq!(options.float_format, FloatFormat::IeeeBigEndian);
780    }
781
782    #[test]
783    fn test_record_format_display() {
784        assert_eq!(format!("{}", RecordFormat::Fixed), "fixed");
785        assert_eq!(format!("{}", RecordFormat::RDW), "rdw");
786    }
787
788    #[test]
789    fn test_codepage_display() {
790        assert_eq!(format!("{}", Codepage::CP037), "cp037");
791        assert_eq!(format!("{}", Codepage::CP273), "cp273");
792        assert_eq!(format!("{}", Codepage::CP500), "cp500");
793        assert_eq!(format!("{}", Codepage::CP1047), "cp1047");
794        assert_eq!(format!("{}", Codepage::CP1140), "cp1140");
795    }
796
797    #[test]
798    fn test_json_number_mode_display() {
799        assert_eq!(format!("{}", JsonNumberMode::Lossless), "lossless");
800        assert_eq!(format!("{}", JsonNumberMode::Native), "native");
801    }
802
803    #[test]
804    fn test_raw_mode_display() {
805        assert_eq!(format!("{}", RawMode::Off), "off");
806        assert_eq!(format!("{}", RawMode::Record), "record");
807        assert_eq!(format!("{}", RawMode::Field), "field");
808        assert_eq!(format!("{}", RawMode::RecordRDW), "record+rdw");
809    }
810
811    #[test]
812    fn test_unmappable_policy_display() {
813        assert_eq!(format!("{}", UnmappablePolicy::Error), "error");
814        assert_eq!(format!("{}", UnmappablePolicy::Replace), "replace");
815        assert_eq!(format!("{}", UnmappablePolicy::Skip), "skip");
816    }
817
818    #[test]
819    fn test_decode_options_serialization() {
820        let options = DecodeOptions {
821            format: DEFAULT_RECORD_FORMAT,
822            codepage: DEFAULT_CODEPAGE,
823            json_number_mode: DEFAULT_JSON_NUMBER_MODE,
824            emit_filler: true,
825            emit_meta: true,
826            emit_raw: RawMode::Record,
827            strict_mode: true,
828            max_errors: Some(100),
829            on_decode_unmappable: UnmappablePolicy::Replace,
830            threads: 4,
831            preserve_zoned_encoding: true,
832            preferred_zoned_encoding: ZonedEncodingFormat::Ebcdic,
833            float_format: FloatFormat::IbmHex,
834        };
835
836        let serialized = serde_json::to_string(&options).unwrap();
837        let deserialized: DecodeOptions = serde_json::from_str(&serialized).unwrap();
838
839        assert_eq!(deserialized.format, RecordFormat::Fixed);
840        assert_eq!(deserialized.codepage, Codepage::CP037);
841        assert!(deserialized.emit_filler);
842        assert!(deserialized.emit_meta);
843        assert_eq!(deserialized.emit_raw, RawMode::Record);
844        assert!(deserialized.strict_mode);
845        assert_eq!(deserialized.max_errors, Some(100));
846        assert_eq!(deserialized.on_decode_unmappable, UnmappablePolicy::Replace);
847        assert_eq!(deserialized.threads, 4);
848        assert!(deserialized.preserve_zoned_encoding);
849        assert_eq!(
850            deserialized.preferred_zoned_encoding,
851            ZonedEncodingFormat::Ebcdic
852        );
853        assert_eq!(deserialized.float_format, FloatFormat::IbmHex);
854    }
855
856    #[test]
857    fn test_decode_options_deserialize_missing_float_format_defaults() {
858        let options = DecodeOptions::default();
859        let mut value = serde_json::to_value(options).unwrap();
860        value.as_object_mut().unwrap().remove("float_format");
861        let deserialized: DecodeOptions = serde_json::from_value(value).unwrap();
862        assert_eq!(deserialized.float_format, FloatFormat::IeeeBigEndian);
863    }
864
865    #[test]
866    fn test_encode_options_deserialize_missing_float_format_defaults() {
867        let options = EncodeOptions::default();
868        let mut value = serde_json::to_value(options).unwrap();
869        value.as_object_mut().unwrap().remove("float_format");
870        let deserialized: EncodeOptions = serde_json::from_value(value).unwrap();
871        assert_eq!(deserialized.float_format, FloatFormat::IeeeBigEndian);
872    }
873}