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}
243
244impl FromStr for RecordFormat {
245    type Err = ParseCodecOptionError;
246
247    fn from_str(input: &str) -> Result<Self, Self::Err> {
248        match input.to_ascii_lowercase().as_str() {
249            "fixed" => Ok(Self::Fixed),
250            "rdw" => Ok(Self::RDW),
251            _ => Err(ParseCodecOptionError::new(
252                CodecOptionKind::RecordFormat,
253                input,
254            )),
255        }
256    }
257}
258
259impl RecordFormat {
260    /// Check if this is a fixed-length record format
261    #[must_use]
262    pub const fn is_fixed(self) -> bool {
263        matches!(self, Self::Fixed)
264    }
265
266    /// Check if this is a variable-length record format
267    #[must_use]
268    pub const fn is_variable(self) -> bool {
269        matches!(self, Self::RDW)
270    }
271
272    /// Get a human-readable description of the format
273    #[must_use]
274    pub const fn description(self) -> &'static str {
275        match self {
276            Self::Fixed => "Fixed-length records",
277            Self::RDW => "Variable-length records with Record Descriptor Word",
278        }
279    }
280}
281
282/// JSON number representation mode
283///
284/// Controls how COBOL numeric fields are represented in JSON output.
285/// `Lossless` preserves exact decimal precision as strings; `Native` uses
286/// JSON number types where the value fits without precision loss.
287///
288/// # Examples
289///
290/// ```
291/// use copybook_codec::options::JsonNumberMode;
292///
293/// let mode = JsonNumberMode::Lossless;
294/// assert!(mode.is_lossless());
295/// assert_eq!(mode.description(), "Lossless string representation for decimals");
296/// ```
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298pub enum JsonNumberMode {
299    /// Lossless string representation for decimals
300    Lossless,
301    /// Native JSON numbers where possible
302    Native,
303}
304
305impl FromStr for JsonNumberMode {
306    type Err = ParseCodecOptionError;
307
308    fn from_str(input: &str) -> Result<Self, Self::Err> {
309        match input.to_ascii_lowercase().as_str() {
310            "lossless" => Ok(Self::Lossless),
311            "native" => Ok(Self::Native),
312            _ => Err(ParseCodecOptionError::new(
313                CodecOptionKind::JsonNumberMode,
314                input,
315            )),
316        }
317    }
318}
319
320impl JsonNumberMode {
321    /// Check if this mode uses lossless string representation
322    #[must_use]
323    #[inline]
324    pub const fn is_lossless(self) -> bool {
325        matches!(self, Self::Lossless)
326    }
327
328    /// Check if this mode uses native JSON numbers
329    #[must_use]
330    #[inline]
331    pub const fn is_native(self) -> bool {
332        matches!(self, Self::Native)
333    }
334
335    /// Get a human-readable description of the mode
336    #[must_use]
337    #[inline]
338    pub const fn description(self) -> &'static str {
339        match self {
340            Self::Lossless => "Lossless string representation for decimals",
341            Self::Native => "Native JSON numbers where possible",
342        }
343    }
344}
345
346/// Raw data capture mode
347///
348/// Controls whether and how binary record data is captured in the output:
349/// - `Off` — no raw data (default, lowest overhead)
350/// - `Record` — record payload in `raw_b64` and compatibility `__raw_b64`
351/// - `RecordRDW` — RDW header plus record payload in `raw_b64` and compatibility `__raw_b64`
352/// - `Field` — only per-field raw bytes in `<FIELD_NAME>_raw_b64`
353///
354/// Whole-record capture also emits `raw_capture` with `record` or `record+rdw`
355/// so RDW replay does not infer framing from byte contents.
356///
357/// # Examples
358///
359/// ```
360/// use copybook_codec::options::RawMode;
361///
362/// let mode = RawMode::Off;
363/// assert_eq!(mode, RawMode::Off);
364///
365/// let field_mode = RawMode::Field;
366/// assert_ne!(field_mode, RawMode::Off);
367/// ```
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
369pub enum RawMode {
370    /// No raw data capture
371    Off,
372    /// Capture record-level raw data
373    Record,
374    /// Capture field-level raw data
375    Field,
376    /// Capture record and RDW header
377    RecordRDW,
378}
379
380impl FromStr for RawMode {
381    type Err = ParseCodecOptionError;
382
383    fn from_str(input: &str) -> Result<Self, Self::Err> {
384        match input.to_ascii_lowercase().as_str() {
385            "off" => Ok(Self::Off),
386            "record" => Ok(Self::Record),
387            "field" => Ok(Self::Field),
388            "record+rdw" => Ok(Self::RecordRDW),
389            _ => Err(ParseCodecOptionError::new(CodecOptionKind::RawMode, input)),
390        }
391    }
392}
393
394impl Default for DecodeOptions {
395    fn default() -> Self {
396        Self {
397            format: DEFAULT_RECORD_FORMAT,
398            codepage: DEFAULT_CODEPAGE,
399            json_number_mode: DEFAULT_JSON_NUMBER_MODE,
400            emit_filler: false,
401            emit_meta: false,
402            emit_raw: RawMode::Off,
403            strict_mode: false,
404            max_errors: None,
405            on_decode_unmappable: DEFAULT_UNMAPPABLE_POLICY,
406            threads: DEFAULT_THREAD_COUNT,
407            preserve_zoned_encoding: false,
408            preferred_zoned_encoding: DEFAULT_ZONED_ENCODING,
409            float_format: DEFAULT_FLOAT_FORMAT,
410        }
411    }
412}
413
414impl DecodeOptions {
415    /// Create new decode options with default values
416    ///
417    /// Returns options configured for:
418    /// - Fixed record format
419    /// - CP037 EBCDIC codepage
420    /// - Lossless JSON number mode
421    /// - Single-threaded processing
422    ///
423    /// Use the builder methods to customize:
424    ///
425    /// # Examples
426    ///
427    /// ```
428    /// use copybook_codec::options::{DecodeOptions, Codepage, JsonNumberMode, RecordFormat, RawMode};
429    ///
430    /// let opts = DecodeOptions::new()
431    ///     .with_codepage(Codepage::CP037)
432    ///     .with_format(RecordFormat::Fixed)
433    ///     .with_json_number_mode(JsonNumberMode::Lossless)
434    ///     .with_emit_meta(true)
435    ///     .with_threads(4);
436    ///
437    /// assert_eq!(opts.threads, 4);
438    /// assert!(opts.emit_meta);
439    /// ```
440    #[must_use]
441    #[inline]
442    pub fn new() -> Self {
443        Self::default()
444    }
445
446    impl_common_option_builders!();
447
448    /// Enable or disable FILLER field emission
449    #[must_use]
450    #[inline]
451    pub fn with_emit_filler(mut self, emit_filler: bool) -> Self {
452        self.emit_filler = emit_filler;
453        self
454    }
455
456    /// Enable or disable metadata emission
457    #[must_use]
458    #[inline]
459    pub fn with_emit_meta(mut self, emit_meta: bool) -> Self {
460        self.emit_meta = emit_meta;
461        self
462    }
463
464    /// Set the raw data capture mode
465    ///
466    /// Controls whether and how raw binary data is included in decode output:
467    /// - `RawMode::Off` — no raw data (default)
468    /// - `RawMode::Record` — record payload in `raw_b64` and compatibility `__raw_b64`
469    /// - `RawMode::RecordRDW` — RDW header plus payload in `raw_b64` and compatibility `__raw_b64`
470    /// - `RawMode::Field` — only per-field raw values in `<FIELD>_raw_b64`
471    ///
472    /// Whole-record capture also emits matching `raw_capture` provenance.
473    #[must_use]
474    #[inline]
475    pub fn with_emit_raw(mut self, emit_raw: RawMode) -> Self {
476        self.emit_raw = emit_raw;
477        self
478    }
479
480    /// Set the policy for unmappable characters
481    #[must_use]
482    #[inline]
483    pub fn with_unmappable_policy(mut self, policy: UnmappablePolicy) -> Self {
484        self.on_decode_unmappable = policy;
485        self
486    }
487
488    // === Zoned Decimal Encoding Configuration ===
489
490    /// Enable zoned decimal encoding preservation for round-trip fidelity
491    ///
492    /// When enabled, the decoder will detect and preserve the original encoding
493    /// format (ASCII vs EBCDIC) for use during subsequent encoding operations.
494    /// This ensures byte-level consistency in encode/decode cycles.
495    #[must_use]
496    #[inline]
497    pub fn with_preserve_zoned_encoding(mut self, preserve_zoned_encoding: bool) -> Self {
498        self.preserve_zoned_encoding = preserve_zoned_encoding;
499        self
500    }
501}
502
503impl Default for EncodeOptions {
504    fn default() -> Self {
505        Self {
506            format: DEFAULT_RECORD_FORMAT,
507            codepage: DEFAULT_CODEPAGE,
508            preferred_zoned_encoding: DEFAULT_ZONED_ENCODING,
509            use_raw: false,
510            bwz_encode: false,
511            strict_mode: false,
512            max_errors: None,
513            threads: DEFAULT_THREAD_COUNT,
514            coerce_numbers: false,
515            on_encode_unmappable: DEFAULT_UNMAPPABLE_POLICY,
516            json_number_mode: DEFAULT_JSON_NUMBER_MODE,
517            zoned_encoding_override: None,
518            float_format: DEFAULT_FLOAT_FORMAT,
519        }
520    }
521}
522
523impl EncodeOptions {
524    /// Create new encode options with default values
525    ///
526    /// Returns options configured for:
527    /// - Fixed record format
528    /// - CP037 EBCDIC codepage
529    /// - Single-threaded processing
530    /// - BLANK WHEN ZERO disabled
531    ///
532    /// Use the builder methods to customize:
533    ///
534    /// # Examples
535    ///
536    /// ```
537    /// use copybook_codec::options::{EncodeOptions, Codepage, RecordFormat};
538    ///
539    /// let opts = EncodeOptions::new()
540    ///     .with_codepage(Codepage::CP037)
541    ///     .with_format(RecordFormat::Fixed)
542    ///     .with_bwz_encode(true)
543    ///     .with_coerce_numbers(true)
544    ///     .with_threads(4);
545    ///
546    /// assert_eq!(opts.threads, 4);
547    /// assert!(opts.bwz_encode);
548    /// assert!(opts.coerce_numbers);
549    /// ```
550    #[must_use]
551    #[inline]
552    pub fn new() -> Self {
553        Self::default()
554    }
555
556    impl_common_option_builders!();
557
558    /// Enable or disable raw data usage
559    #[must_use]
560    #[inline]
561    pub fn with_use_raw(mut self, use_raw: bool) -> Self {
562        self.use_raw = use_raw;
563        self
564    }
565
566    /// Enable or disable BLANK WHEN ZERO encoding
567    #[must_use]
568    #[inline]
569    pub fn with_bwz_encode(mut self, bwz_encode: bool) -> Self {
570        self.bwz_encode = bwz_encode;
571        self
572    }
573
574    /// Enable or disable number coercion
575    #[must_use]
576    #[inline]
577    pub fn with_coerce_numbers(mut self, coerce_numbers: bool) -> Self {
578        self.coerce_numbers = coerce_numbers;
579        self
580    }
581
582    /// Set the policy for unmappable characters during encoding
583    #[must_use]
584    #[inline]
585    pub fn with_unmappable_policy(mut self, policy: UnmappablePolicy) -> Self {
586        self.on_encode_unmappable = policy;
587        self
588    }
589
590    /// Set explicit zoned decimal encoding format override
591    ///
592    /// Forces all zoned decimal fields to use the specified encoding format,
593    /// overriding any preserved format from decode operations. Use `None` to
594    /// disable override and respect preserved formats.
595    #[must_use]
596    #[inline]
597    pub fn with_zoned_encoding_override(
598        mut self,
599        zoned_encoding_override: Option<ZonedEncodingFormat>,
600    ) -> Self {
601        self.zoned_encoding_override = zoned_encoding_override;
602        self
603    }
604
605    /// Convenience method to set explicit zoned encoding format
606    ///
607    /// Equivalent to `with_zoned_encoding_override(Some(format))`.
608    #[must_use]
609    #[inline]
610    pub fn with_zoned_encoding_format(mut self, format: ZonedEncodingFormat) -> Self {
611        self.zoned_encoding_override = Some(format);
612        self
613    }
614}
615impl fmt::Display for RecordFormat {
616    #[inline]
617    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
618        match self {
619            Self::Fixed => write!(f, "fixed"),
620            Self::RDW => write!(f, "rdw"),
621        }
622    }
623}
624
625impl fmt::Display for JsonNumberMode {
626    #[inline]
627    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
628        match self {
629            Self::Lossless => write!(f, "lossless"),
630            Self::Native => write!(f, "native"),
631        }
632    }
633}
634
635impl fmt::Display for RawMode {
636    #[inline]
637    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
638        match self {
639            Self::Off => write!(f, "off"),
640            Self::Record => write!(f, "record"),
641            Self::Field => write!(f, "field"),
642            Self::RecordRDW => write!(f, "record+rdw"),
643        }
644    }
645}
646
647impl fmt::Display for FloatFormat {
648    #[inline]
649    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
650        match self {
651            Self::IeeeBigEndian => write!(f, "ieee-be"),
652            Self::IbmHex => write!(f, "ibm-hex"),
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn test_zoned_encoding_format_is_ascii() {
663        assert!(ZonedEncodingFormat::Ascii.is_ascii());
664        assert!(!ZonedEncodingFormat::Ebcdic.is_ascii());
665        assert!(!ZonedEncodingFormat::Auto.is_ascii());
666    }
667
668    #[test]
669    fn test_zoned_encoding_format_is_ebcdic() {
670        assert!(!ZonedEncodingFormat::Ascii.is_ebcdic());
671        assert!(ZonedEncodingFormat::Ebcdic.is_ebcdic());
672        assert!(!ZonedEncodingFormat::Auto.is_ebcdic());
673    }
674
675    #[test]
676    fn test_zoned_encoding_format_is_auto() {
677        assert!(!ZonedEncodingFormat::Ascii.is_auto());
678        assert!(!ZonedEncodingFormat::Ebcdic.is_auto());
679        assert!(ZonedEncodingFormat::Auto.is_auto());
680    }
681
682    #[test]
683    fn test_zoned_encoding_format_description() {
684        assert_eq!(
685            ZonedEncodingFormat::Ascii.description(),
686            "ASCII digit zones (0x30-0x39)"
687        );
688        assert_eq!(
689            ZonedEncodingFormat::Ebcdic.description(),
690            "EBCDIC digit zones (0xF0-0xF9)"
691        );
692        assert_eq!(
693            ZonedEncodingFormat::Auto.description(),
694            "Automatic detection based on zone nibbles"
695        );
696    }
697
698    #[test]
699    fn test_zoned_encoding_format_detect_from_byte() {
700        // ASCII zone nibble (0x30)
701        assert_eq!(
702            ZonedEncodingFormat::detect_from_byte(0x35),
703            Some(ZonedEncodingFormat::Ascii)
704        );
705        assert_eq!(
706            ZonedEncodingFormat::detect_from_byte(0x30),
707            Some(ZonedEncodingFormat::Ascii)
708        );
709        assert_eq!(
710            ZonedEncodingFormat::detect_from_byte(0x39),
711            Some(ZonedEncodingFormat::Ascii)
712        );
713
714        // EBCDIC zone nibble (0xF0)
715        assert_eq!(
716            ZonedEncodingFormat::detect_from_byte(0xF5),
717            Some(ZonedEncodingFormat::Ebcdic)
718        );
719        assert_eq!(
720            ZonedEncodingFormat::detect_from_byte(0xF0),
721            Some(ZonedEncodingFormat::Ebcdic)
722        );
723        assert_eq!(
724            ZonedEncodingFormat::detect_from_byte(0xF9),
725            Some(ZonedEncodingFormat::Ebcdic)
726        );
727
728        // Invalid zone nibbles (0x00, 0x50)
729        assert_eq!(ZonedEncodingFormat::detect_from_byte(0x00), None);
730        assert_eq!(ZonedEncodingFormat::detect_from_byte(0x50), None);
731        // Note: 0xFF matches EBCDIC_ZONE (0x0F), so it returns Some(Ebcdic)
732        assert_eq!(
733            ZonedEncodingFormat::detect_from_byte(0xFF),
734            Some(ZonedEncodingFormat::Ebcdic)
735        );
736    }
737
738    #[test]
739    fn test_zoned_encoding_format_display() {
740        assert_eq!(format!("{}", ZonedEncodingFormat::Ascii), "ascii");
741        assert_eq!(format!("{}", ZonedEncodingFormat::Ebcdic), "ebcdic");
742        assert_eq!(format!("{}", ZonedEncodingFormat::Auto), "auto");
743    }
744
745    #[test]
746    fn test_decode_options_default() {
747        let options = DecodeOptions::default();
748        assert_eq!(options.format, RecordFormat::Fixed);
749        assert_eq!(options.codepage, Codepage::CP037);
750        assert_eq!(options.json_number_mode, JsonNumberMode::Lossless);
751        assert!(!options.emit_filler);
752        assert!(!options.emit_meta);
753        assert_eq!(options.emit_raw, RawMode::Off);
754        assert!(!options.strict_mode);
755        assert!(options.max_errors.is_none());
756        assert_eq!(options.on_decode_unmappable, UnmappablePolicy::Error);
757        assert_eq!(options.threads, 1);
758        assert!(!options.preserve_zoned_encoding);
759        assert_eq!(options.preferred_zoned_encoding, ZonedEncodingFormat::Auto);
760        assert_eq!(options.float_format, FloatFormat::IeeeBigEndian);
761    }
762
763    #[test]
764    fn test_encode_options_default() {
765        let options = EncodeOptions::default();
766        assert_eq!(options.format, RecordFormat::Fixed);
767        assert_eq!(options.codepage, Codepage::CP037);
768        assert_eq!(options.preferred_zoned_encoding, ZonedEncodingFormat::Auto);
769        assert!(!options.use_raw);
770        assert!(!options.bwz_encode);
771        assert!(!options.strict_mode);
772        assert_eq!(options.on_encode_unmappable, UnmappablePolicy::Error);
773        assert_eq!(options.json_number_mode, JsonNumberMode::Lossless);
774        assert_eq!(options.float_format, FloatFormat::IeeeBigEndian);
775    }
776
777    #[test]
778    fn test_record_format_display() {
779        assert_eq!(format!("{}", RecordFormat::Fixed), "fixed");
780        assert_eq!(format!("{}", RecordFormat::RDW), "rdw");
781    }
782
783    #[test]
784    fn test_codepage_display() {
785        assert_eq!(format!("{}", Codepage::CP037), "cp037");
786        assert_eq!(format!("{}", Codepage::CP273), "cp273");
787        assert_eq!(format!("{}", Codepage::CP500), "cp500");
788        assert_eq!(format!("{}", Codepage::CP1047), "cp1047");
789        assert_eq!(format!("{}", Codepage::CP1140), "cp1140");
790    }
791
792    #[test]
793    fn test_json_number_mode_display() {
794        assert_eq!(format!("{}", JsonNumberMode::Lossless), "lossless");
795        assert_eq!(format!("{}", JsonNumberMode::Native), "native");
796    }
797
798    #[test]
799    fn test_raw_mode_display() {
800        assert_eq!(format!("{}", RawMode::Off), "off");
801        assert_eq!(format!("{}", RawMode::Record), "record");
802        assert_eq!(format!("{}", RawMode::Field), "field");
803        assert_eq!(format!("{}", RawMode::RecordRDW), "record+rdw");
804    }
805
806    #[test]
807    fn test_unmappable_policy_display() {
808        assert_eq!(format!("{}", UnmappablePolicy::Error), "error");
809        assert_eq!(format!("{}", UnmappablePolicy::Replace), "replace");
810        assert_eq!(format!("{}", UnmappablePolicy::Skip), "skip");
811    }
812
813    #[test]
814    fn test_decode_options_serialization() {
815        let options = DecodeOptions {
816            format: DEFAULT_RECORD_FORMAT,
817            codepage: DEFAULT_CODEPAGE,
818            json_number_mode: DEFAULT_JSON_NUMBER_MODE,
819            emit_filler: true,
820            emit_meta: true,
821            emit_raw: RawMode::Record,
822            strict_mode: true,
823            max_errors: Some(100),
824            on_decode_unmappable: UnmappablePolicy::Replace,
825            threads: 4,
826            preserve_zoned_encoding: true,
827            preferred_zoned_encoding: ZonedEncodingFormat::Ebcdic,
828            float_format: FloatFormat::IbmHex,
829        };
830
831        let serialized = serde_json::to_string(&options).unwrap();
832        let deserialized: DecodeOptions = serde_json::from_str(&serialized).unwrap();
833
834        assert_eq!(deserialized.format, RecordFormat::Fixed);
835        assert_eq!(deserialized.codepage, Codepage::CP037);
836        assert!(deserialized.emit_filler);
837        assert!(deserialized.emit_meta);
838        assert_eq!(deserialized.emit_raw, RawMode::Record);
839        assert!(deserialized.strict_mode);
840        assert_eq!(deserialized.max_errors, Some(100));
841        assert_eq!(deserialized.on_decode_unmappable, UnmappablePolicy::Replace);
842        assert_eq!(deserialized.threads, 4);
843        assert!(deserialized.preserve_zoned_encoding);
844        assert_eq!(
845            deserialized.preferred_zoned_encoding,
846            ZonedEncodingFormat::Ebcdic
847        );
848        assert_eq!(deserialized.float_format, FloatFormat::IbmHex);
849    }
850
851    #[test]
852    fn test_decode_options_deserialize_missing_float_format_defaults() {
853        let options = DecodeOptions::default();
854        let mut value = serde_json::to_value(options).unwrap();
855        value.as_object_mut().unwrap().remove("float_format");
856        let deserialized: DecodeOptions = serde_json::from_value(value).unwrap();
857        assert_eq!(deserialized.float_format, FloatFormat::IeeeBigEndian);
858    }
859
860    #[test]
861    fn test_encode_options_deserialize_missing_float_format_defaults() {
862        let options = EncodeOptions::default();
863        let mut value = serde_json::to_value(options).unwrap();
864        value.as_object_mut().unwrap().remove("float_format");
865        let deserialized: EncodeOptions = serde_json::from_value(value).unwrap();
866        assert_eq!(deserialized.float_format, FloatFormat::IeeeBigEndian);
867    }
868}