Skip to main content

copybook_codec/file/
fixed.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Fixed-LRECL framing integration for schema-backed codec operations.
4//!
5//! `copybook-fixed` owns byte-stream framing and has no dependency on schema
6//! types. This module is the codec boundary that resolves a copybook schema's
7//! fixed LRECL before constructing those framing primitives.
8
9use copybook_core::Schema;
10use copybook_error::{Error, ErrorCode, ErrorContext, Result};
11use copybook_fixed::FixedRecordReader;
12use std::convert::TryFrom;
13use std::io::Read;
14use tracing::{debug, warn};
15
16pub(crate) const FIXED_FORMAT_LRECL_MISSING: &str = "Fixed format requires a fixed record length (LRECL). Set schema.lrecl_fixed or use RecordFormat::Variable.";
17
18/// Resolve the fixed record length required by a schema.
19///
20/// # Errors
21/// Returns `CBKI001_INVALID_STATE` when the schema does not provide a fixed
22/// record length.
23#[inline]
24#[must_use = "Handle the Result or propagate the error"]
25pub fn lrecl(schema: &Schema) -> Result<u32> {
26    let lrecl = schema
27        .lrecl_fixed
28        .ok_or_else(|| Error::new(ErrorCode::CBKI001_INVALID_STATE, FIXED_FORMAT_LRECL_MISSING))?;
29
30    if lrecl == 0 {
31        return Err(Error::new(
32            ErrorCode::CBKI001_INVALID_STATE,
33            "LRECL must be greater than zero",
34        ));
35    }
36
37    Ok(lrecl)
38}
39
40/// Construct a fixed reader after resolving the schema's LRECL in the codec.
41///
42/// The returned framing primitive accepts only the explicit LRECL value; no
43/// schema type crosses into `copybook-fixed`.
44///
45/// # Errors
46/// Returns an error when the schema has no fixed LRECL or its LRECL is invalid.
47#[inline]
48#[must_use = "Handle the Result or propagate the error"]
49pub fn reader<R: Read>(input: R, schema: &Schema) -> Result<FixedRecordReader<R>> {
50    FixedRecordReader::with_lrecl(input, lrecl(schema)?)
51}
52
53/// Validate a fixed record against the codec's schema and framing contract.
54///
55/// This check intentionally lives beside schema-to-framing integration rather
56/// than in `copybook-fixed`, so the framing crate remains schema-independent.
57///
58/// # Errors
59/// Returns an error when the configured LRECL cannot be represented on the
60/// current platform or when the record length does not match it.
61#[inline]
62#[must_use = "Handle the Result or propagate the error"]
63pub fn validate_record_length(
64    schema: &Schema,
65    configured_lrecl: u32,
66    record_index: u64,
67    record_data: &[u8],
68) -> Result<()> {
69    let lrecl_len = usize::try_from(configured_lrecl).map_err(|_| {
70        Error::new(
71            ErrorCode::CBKR101_FIXED_RECORD_ERROR,
72            "LRECL exceeds platform addressable size",
73        )
74    })?;
75
76    if record_data.len() != lrecl_len {
77        return Err(Error::new(
78            ErrorCode::CBKR101_FIXED_RECORD_ERROR,
79            format!(
80                "Record length mismatch: expected {}, got {}",
81                configured_lrecl,
82                record_data.len()
83            ),
84        )
85        .with_context(ErrorContext {
86            record_index: Some(record_index),
87            field_path: None,
88            byte_offset: None,
89            line_number: None,
90            details: Some("Fixed record length validation failed".to_string()),
91        }));
92    }
93
94    if let Some(schema_lrecl) = schema.lrecl_fixed
95        && configured_lrecl != schema_lrecl
96    {
97        warn!(
98            "LRECL mismatch: codec configured for {}, schema expects {}",
99            configured_lrecl, schema_lrecl
100        );
101    }
102
103    if schema.tail_odo.is_some() {
104        debug!("Record has ODO tail, variable length within fixed LRECL is expected");
105    }
106
107    Ok(())
108}
109
110#[cfg(test)]
111#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
112mod tests {
113    use super::*;
114    use std::io::Cursor;
115
116    #[test]
117    fn resolves_schema_lrecl_at_codec_boundary() {
118        let mut schema = Schema::new();
119        schema.lrecl_fixed = Some(8);
120
121        assert_eq!(lrecl(&schema).unwrap(), 8);
122        let mut reader = reader(Cursor::new(b"ABCDEFGH"), &schema).unwrap();
123        assert_eq!(reader.read_record().unwrap().unwrap(), b"ABCDEFGH");
124    }
125
126    #[test]
127    fn rejects_schema_without_fixed_lrecl_at_codec_boundary() {
128        let schema = Schema::new();
129
130        let error = lrecl(&schema).unwrap_err();
131        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
132    }
133
134    #[test]
135    fn rejects_zero_schema_lrecl_at_codec_boundary() {
136        let mut schema = Schema::new();
137        schema.lrecl_fixed = Some(0);
138
139        let error = lrecl(&schema).unwrap_err();
140        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
141        assert_eq!(error.message, "LRECL must be greater than zero");
142    }
143
144    #[test]
145    fn validates_record_length_at_codec_boundary() {
146        let mut schema = Schema::new();
147        schema.lrecl_fixed = Some(8);
148
149        validate_record_length(&schema, 8, 1, b"ABCDEFGH").unwrap();
150        let error = validate_record_length(&schema, 8, 2, b"SHORT").unwrap_err();
151        assert_eq!(error.code, ErrorCode::CBKR101_FIXED_RECORD_ERROR);
152    }
153}