Skip to main content

copybook_fixed/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Fixed-length record framing primitives.
4//!
5//! This crate intentionally focuses on one concern:
6//! reading and writing LRECL-framed records with deterministic padding and
7//! structured error mapping.
8//!
9//! Use [`FixedRecordReader`] to consume fixed-length records from a byte stream
10//! and [`FixedRecordWriter`] to produce them with automatic null-byte padding.
11
12use copybook_error::{Error, ErrorCode, ErrorContext, Result};
13use std::convert::TryFrom;
14use std::io::{ErrorKind, Read, Write};
15use tracing::debug;
16
17/// Fixed record reader for processing fixed-length records.
18#[derive(Debug)]
19pub struct FixedRecordReader<R: Read> {
20    input: R,
21    lrecl: u32,
22    record_count: u64,
23}
24
25impl<R: Read> FixedRecordReader<R> {
26    /// Create a reader for an explicit fixed-record length.
27    ///
28    /// This is the canonical schema-independent constructor. Schema
29    /// compatibility belongs to the codec integration layer, not this
30    /// framing crate.
31    ///
32    /// # Errors
33    /// Returns an error if `lrecl` is zero.
34    #[inline]
35    #[must_use = "Handle the Result or propagate the error"]
36    pub fn with_lrecl(input: R, lrecl: u32) -> Result<Self> {
37        Self::new(input, Some(lrecl))
38    }
39
40    /// Create a new fixed record reader.
41    ///
42    /// # Errors
43    /// Returns an error if no LRECL is provided or if it is zero.
44    #[inline]
45    #[must_use = "Handle the Result or propagate the error"]
46    pub fn new(input: R, lrecl: Option<u32>) -> Result<Self> {
47        let lrecl = lrecl.ok_or_else(|| {
48            Error::new(
49                ErrorCode::CBKI001_INVALID_STATE,
50                "Fixed format requires LRECL",
51            )
52        })?;
53
54        if lrecl == 0 {
55            return Err(Error::new(
56                ErrorCode::CBKI001_INVALID_STATE,
57                "LRECL must be greater than zero",
58            ));
59        }
60
61        Ok(Self {
62            input,
63            lrecl,
64            record_count: 0,
65        })
66    }
67
68    /// Read the next record.
69    ///
70    /// # Errors
71    /// Returns an error if the record cannot be read due to I/O errors.
72    #[inline]
73    #[must_use = "Handle the Result or propagate the error"]
74    pub fn read_record(&mut self) -> Result<Option<Vec<u8>>> {
75        // Read one byte first so true EOF can be treated as `Ok(None)`.
76        let mut first_byte = [0u8; 1];
77        match self.input.read_exact(&mut first_byte) {
78            Ok(()) => {
79                let lrecl_len = self.lrecl_usize()?;
80                let mut buffer = vec![0u8; lrecl_len];
81                buffer[0] = first_byte[0];
82
83                if lrecl_len > 1 {
84                    match self.input.read_exact(&mut buffer[1..]) {
85                        Ok(()) => {
86                            self.record_count += 1;
87                            debug!(
88                                "Read fixed record {} of {} bytes",
89                                self.record_count, self.lrecl
90                            );
91                            Ok(Some(buffer))
92                        }
93                        Err(e) if e.kind() == ErrorKind::UnexpectedEof => Err(Error::new(
94                            ErrorCode::CBKR101_FIXED_RECORD_ERROR,
95                            format!(
96                                "Incomplete record at end of file: expected {} bytes",
97                                self.lrecl
98                            ),
99                        )
100                        .with_context(ErrorContext {
101                            record_index: Some(self.record_count + 1),
102                            field_path: None,
103                            byte_offset: None,
104                            line_number: None,
105                            details: Some("File ends with partial record".to_string()),
106                        })),
107                        Err(e) => Err(Error::new(
108                            ErrorCode::CBKR101_FIXED_RECORD_ERROR,
109                            format!("I/O error reading record: {e}"),
110                        )
111                        .with_context(ErrorContext {
112                            record_index: Some(self.record_count + 1),
113                            field_path: None,
114                            byte_offset: None,
115                            line_number: None,
116                            details: None,
117                        })),
118                    }
119                } else {
120                    self.record_count += 1;
121                    debug!(
122                        "Read fixed record {} of {} bytes",
123                        self.record_count, self.lrecl
124                    );
125                    Ok(Some(buffer))
126                }
127            }
128            Err(e) if e.kind() == ErrorKind::UnexpectedEof => {
129                debug!("Reached EOF after {} records", self.record_count);
130                Ok(None)
131            }
132            Err(e) => Err(Error::new(
133                ErrorCode::CBKR101_FIXED_RECORD_ERROR,
134                format!("I/O error reading record: {e}"),
135            )
136            .with_context(ErrorContext {
137                record_index: Some(self.record_count + 1),
138                field_path: None,
139                byte_offset: None,
140                line_number: None,
141                details: None,
142            })),
143        }
144    }
145
146    /// Get the current record count.
147    #[must_use]
148    #[inline]
149    pub fn record_count(&self) -> u64 {
150        self.record_count
151    }
152
153    /// Get the configured LRECL.
154    #[must_use]
155    #[inline]
156    pub fn lrecl(&self) -> u32 {
157        self.lrecl
158    }
159
160    #[inline]
161    fn lrecl_usize(&self) -> Result<usize> {
162        usize::try_from(self.lrecl).map_err(|_| {
163            Error::new(
164                ErrorCode::CBKR101_FIXED_RECORD_ERROR,
165                "LRECL exceeds platform addressable size",
166            )
167        })
168    }
169}
170
171/// Fixed record writer for writing fixed-length records.
172#[derive(Debug)]
173pub struct FixedRecordWriter<W: Write> {
174    output: W,
175    lrecl: u32,
176    record_count: u64,
177}
178
179impl<W: Write> FixedRecordWriter<W> {
180    /// Create a writer for an explicit fixed-record length.
181    ///
182    /// This is the canonical schema-independent constructor. Schema
183    /// compatibility belongs to the codec integration layer, not this
184    /// framing crate.
185    ///
186    /// # Errors
187    /// Returns an error if `lrecl` is zero.
188    #[inline]
189    #[must_use = "Handle the Result or propagate the error"]
190    pub fn with_lrecl(output: W, lrecl: u32) -> Result<Self> {
191        Self::new(output, Some(lrecl))
192    }
193
194    /// Create a new fixed record writer.
195    ///
196    /// # Errors
197    /// Returns an error if no LRECL is provided or if it is zero.
198    #[inline]
199    #[must_use = "Handle the Result or propagate the error"]
200    pub fn new(output: W, lrecl: Option<u32>) -> Result<Self> {
201        let lrecl = lrecl.ok_or_else(|| {
202            Error::new(
203                ErrorCode::CBKI001_INVALID_STATE,
204                "Fixed format requires LRECL",
205            )
206        })?;
207
208        if lrecl == 0 {
209            return Err(Error::new(
210                ErrorCode::CBKI001_INVALID_STATE,
211                "LRECL must be greater than zero",
212            ));
213        }
214
215        Ok(Self {
216            output,
217            lrecl,
218            record_count: 0,
219        })
220    }
221
222    /// Write a record and pad with `0x00` to LRECL.
223    ///
224    /// # Errors
225    /// Returns an error if the record is longer than LRECL or I/O fails.
226    #[inline]
227    #[must_use = "Handle the Result or propagate the error"]
228    pub fn write_record(&mut self, data: &[u8]) -> Result<()> {
229        let data_len = data.len();
230        let lrecl = self.lrecl_usize()?;
231
232        if data_len > lrecl {
233            return Err(Error::new(
234                ErrorCode::CBKR101_FIXED_RECORD_ERROR,
235                format!("Record too long: {data_len} bytes exceeds LRECL of {lrecl}"),
236            )
237            .with_context(ErrorContext {
238                record_index: Some(self.record_count + 1),
239                field_path: None,
240                byte_offset: None,
241                line_number: None,
242                details: Some("Record exceeds fixed length".to_string()),
243            }));
244        }
245
246        self.output.write_all(data).map_err(|e| {
247            Error::new(
248                ErrorCode::CBKR101_FIXED_RECORD_ERROR,
249                format!("I/O error writing record: {e}"),
250            )
251            .with_context(ErrorContext {
252                record_index: Some(self.record_count + 1),
253                field_path: None,
254                byte_offset: None,
255                line_number: None,
256                details: None,
257            })
258        })?;
259
260        if data_len < lrecl {
261            let padding = vec![0u8; lrecl - data_len];
262            self.output.write_all(&padding).map_err(|e| {
263                Error::new(
264                    ErrorCode::CBKR101_FIXED_RECORD_ERROR,
265                    format!("I/O error writing padding: {e}"),
266                )
267                .with_context(ErrorContext {
268                    record_index: Some(self.record_count + 1),
269                    field_path: None,
270                    byte_offset: Some(u64::try_from(data_len).unwrap_or(u64::MAX)),
271                    line_number: None,
272                    details: Some("Error writing record padding".to_string()),
273                })
274            })?;
275        }
276
277        self.record_count += 1;
278        debug!(
279            "Wrote fixed record {} of {} bytes (data: {}, padding: {})",
280            self.record_count,
281            lrecl,
282            data_len,
283            lrecl - data_len
284        );
285        Ok(())
286    }
287
288    /// Flush the output.
289    ///
290    /// # Errors
291    /// Returns an error if the flush operation fails.
292    #[inline]
293    #[must_use = "Handle the Result or propagate the error"]
294    pub fn flush(&mut self) -> Result<()> {
295        self.output.flush().map_err(|e| {
296            Error::new(
297                ErrorCode::CBKR101_FIXED_RECORD_ERROR,
298                format!("I/O error flushing output: {e}"),
299            )
300        })
301    }
302
303    /// Get the current record count.
304    #[must_use]
305    #[inline]
306    pub fn record_count(&self) -> u64 {
307        self.record_count
308    }
309
310    /// Get the configured LRECL.
311    #[must_use]
312    #[inline]
313    pub fn lrecl(&self) -> u32 {
314        self.lrecl
315    }
316
317    #[inline]
318    fn lrecl_usize(&self) -> Result<usize> {
319        usize::try_from(self.lrecl).map_err(|_| {
320            Error::new(
321                ErrorCode::CBKR101_FIXED_RECORD_ERROR,
322                "LRECL exceeds platform addressable size",
323            )
324        })
325    }
326}
327
328#[cfg(test)]
329#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
330mod tests {
331    use super::*;
332    use proptest::collection::vec;
333    use proptest::prelude::*;
334    use std::io::Cursor;
335
336    #[test]
337    fn fixed_record_reader_basic() {
338        let data = b"ABCD1234EFGH5678";
339        let mut reader = FixedRecordReader::new(Cursor::new(data), Some(8)).unwrap();
340
341        let record1 = reader.read_record().unwrap().unwrap();
342        assert_eq!(record1, b"ABCD1234");
343        assert_eq!(reader.record_count(), 1);
344
345        let record2 = reader.read_record().unwrap().unwrap();
346        assert_eq!(record2, b"EFGH5678");
347        assert_eq!(reader.record_count(), 2);
348
349        let record3 = reader.read_record().unwrap();
350        assert!(record3.is_none());
351    }
352
353    #[test]
354    fn explicit_lrecl_reader_constructor() {
355        let mut reader = FixedRecordReader::with_lrecl(Cursor::new(b"ABCD"), 4).unwrap();
356
357        assert_eq!(reader.read_record().unwrap().unwrap(), b"ABCD");
358    }
359
360    #[test]
361    fn explicit_lrecl_reader_constructor_rejects_zero() {
362        let error = FixedRecordReader::with_lrecl(Cursor::new(b"ABCD"), 0).unwrap_err();
363
364        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
365    }
366
367    #[test]
368    fn fixed_record_reader_partial_record_is_fixed_record_error() {
369        let data = b"ABCD123";
370        let mut reader = FixedRecordReader::new(Cursor::new(data), Some(8)).unwrap();
371
372        let error = reader.read_record().unwrap_err();
373        assert_eq!(error.code, ErrorCode::CBKR101_FIXED_RECORD_ERROR);
374    }
375
376    #[test]
377    fn fixed_record_reader_zero_lrecl_is_invalid_state() {
378        let data = b"test";
379        let error = FixedRecordReader::new(Cursor::new(data), Some(0)).unwrap_err();
380        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
381    }
382
383    #[test]
384    fn fixed_record_reader_missing_lrecl_is_invalid_state() {
385        let data = b"test";
386        let error = FixedRecordReader::new(Cursor::new(data), None).unwrap_err();
387        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
388    }
389
390    #[test]
391    fn fixed_record_writer_basic() {
392        let mut output = Vec::new();
393        let mut writer = FixedRecordWriter::new(&mut output, Some(8)).unwrap();
394
395        writer.write_record(b"ABCD1234").unwrap();
396        writer.write_record(b"XYZ").unwrap();
397        writer.flush().unwrap();
398
399        assert_eq!(writer.record_count(), 2);
400        assert_eq!(output, b"ABCD1234XYZ\x00\x00\x00\x00\x00");
401    }
402
403    #[test]
404    fn explicit_lrecl_writer_constructor() {
405        let mut output = Vec::new();
406        let mut writer = FixedRecordWriter::with_lrecl(&mut output, 4).unwrap();
407
408        writer.write_record(b"AB").unwrap();
409        assert_eq!(output, b"AB\x00\x00");
410    }
411
412    #[test]
413    fn explicit_lrecl_writer_constructor_rejects_zero() {
414        let mut output = Vec::new();
415        let error = FixedRecordWriter::with_lrecl(&mut output, 0).unwrap_err();
416
417        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
418    }
419
420    #[test]
421    fn fixed_record_writer_too_long_is_fixed_record_error() {
422        let mut output = Vec::new();
423        let mut writer = FixedRecordWriter::new(&mut output, Some(4)).unwrap();
424
425        let error = writer.write_record(b"ABCDEFGH").unwrap_err();
426        assert_eq!(error.code, ErrorCode::CBKR101_FIXED_RECORD_ERROR);
427    }
428
429    #[test]
430    fn fixed_record_writer_zero_lrecl_is_invalid_state() {
431        let mut output = Vec::new();
432        let error = FixedRecordWriter::new(&mut output, Some(0)).unwrap_err();
433        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
434    }
435
436    #[test]
437    fn fixed_record_writer_missing_lrecl_is_invalid_state() {
438        let mut output = Vec::new();
439        let error = FixedRecordWriter::new(&mut output, None).unwrap_err();
440        assert_eq!(error.code, ErrorCode::CBKI001_INVALID_STATE);
441    }
442
443    proptest! {
444        #[test]
445        fn prop_fixed_writer_reader_roundtrip(
446            lrecl in 1u16..=512u16,
447            payload in vec(any::<u8>(), 0..=512),
448        ) {
449            prop_assume!(payload.len() <= usize::from(lrecl));
450            let mut encoded = Vec::new();
451            let mut writer = FixedRecordWriter::new(&mut encoded, Some(u32::from(lrecl))).unwrap();
452            writer.write_record(&payload).unwrap();
453            writer.flush().unwrap();
454            prop_assert_eq!(encoded.len(), usize::from(lrecl));
455            prop_assert_eq!(&encoded[..payload.len()], payload.as_slice());
456
457            let mut reader = FixedRecordReader::new(Cursor::new(&encoded), Some(u32::from(lrecl))).unwrap();
458            let decoded = reader.read_record().unwrap().unwrap();
459            prop_assert_eq!(decoded, encoded.as_slice());
460            prop_assert_eq!(reader.read_record().unwrap(), None);
461        }
462
463        #[test]
464        fn prop_fixed_writer_rejects_oversize_payload(
465            lrecl in 1u16..=128u16,
466            extra in 1usize..=64usize,
467        ) {
468            let mut output = Vec::new();
469            let mut writer = FixedRecordWriter::new(&mut output, Some(u32::from(lrecl))).unwrap();
470            let payload = vec![0x41; usize::from(lrecl) + extra];
471            let error = writer.write_record(&payload).unwrap_err();
472            prop_assert_eq!(error.code, ErrorCode::CBKR101_FIXED_RECORD_ERROR);
473        }
474    }
475
476    // ---- additional coverage for fixed-length framing ----
477
478    #[test]
479    fn fixed_reader_empty_file_returns_none() {
480        let mut reader = FixedRecordReader::new(Cursor::new(Vec::<u8>::new()), Some(8)).unwrap();
481        assert!(reader.read_record().unwrap().is_none());
482        assert_eq!(reader.record_count(), 0);
483    }
484
485    #[test]
486    fn fixed_reader_single_byte_lrecl() {
487        let data = b"ABCDE";
488        let mut reader = FixedRecordReader::new(Cursor::new(data.as_slice()), Some(1)).unwrap();
489        for expected in b"ABCDE" {
490            let record = reader.read_record().unwrap().unwrap();
491            assert_eq!(record, vec![*expected]);
492        }
493        assert!(reader.read_record().unwrap().is_none());
494        assert_eq!(reader.record_count(), 5);
495    }
496
497    #[test]
498    fn fixed_reader_lrecl_accessor() {
499        let reader = FixedRecordReader::new(Cursor::new(Vec::<u8>::new()), Some(42)).unwrap();
500        assert_eq!(reader.lrecl(), 42);
501    }
502
503    #[test]
504    fn fixed_writer_lrecl_accessor() {
505        let mut output = Vec::new();
506        let writer = FixedRecordWriter::new(&mut output, Some(42)).unwrap();
507        assert_eq!(writer.lrecl(), 42);
508    }
509
510    #[test]
511    fn fixed_writer_exact_lrecl_no_padding() {
512        let mut output = Vec::new();
513        let mut writer = FixedRecordWriter::new(&mut output, Some(4)).unwrap();
514        writer.write_record(b"ABCD").unwrap();
515        writer.flush().unwrap();
516        assert_eq!(output, b"ABCD");
517    }
518
519    #[test]
520    fn fixed_writer_empty_payload_full_padding() {
521        let mut output = Vec::new();
522        let mut writer = FixedRecordWriter::new(&mut output, Some(4)).unwrap();
523        writer.write_record(b"").unwrap();
524        writer.flush().unwrap();
525        assert_eq!(output, vec![0u8; 4]);
526    }
527
528    #[test]
529    fn fixed_multi_record_write_read_roundtrip() {
530        let lrecl = 10u32;
531        let payloads: Vec<&[u8]> = vec![b"AAAAAAAAAA", b"BB", b"CCCCCCCCCC"];
532        let mut encoded = Vec::new();
533        {
534            let mut writer = FixedRecordWriter::new(&mut encoded, Some(lrecl)).unwrap();
535            for p in &payloads {
536                writer.write_record(p).unwrap();
537            }
538            writer.flush().unwrap();
539            assert_eq!(writer.record_count(), 3);
540        }
541        assert_eq!(encoded.len(), 30);
542
543        let mut reader = FixedRecordReader::new(Cursor::new(&encoded), Some(lrecl)).unwrap();
544        for (i, expected) in payloads.iter().enumerate() {
545            let record = reader.read_record().unwrap().unwrap();
546            assert_eq!(
547                &record[..expected.len()],
548                *expected,
549                "record {i} data mismatch"
550            );
551            // remaining bytes are zero-padded
552            assert!(
553                record[expected.len()..].iter().all(|&b| b == 0),
554                "record {i} padding mismatch"
555            );
556        }
557        assert!(reader.read_record().unwrap().is_none());
558        assert_eq!(reader.record_count(), 3);
559    }
560
561    #[test]
562    fn fixed_streaming_many_records() {
563        let lrecl = 16u32;
564        let record_count = 500u64;
565        let payload = b"STREAMING_FIXED_";
566        assert_eq!(payload.len(), lrecl as usize);
567
568        let mut encoded = Vec::new();
569        {
570            let mut writer = FixedRecordWriter::new(&mut encoded, Some(lrecl)).unwrap();
571            for _ in 0..record_count {
572                writer.write_record(payload).unwrap();
573            }
574            writer.flush().unwrap();
575        }
576
577        let mut reader = FixedRecordReader::new(Cursor::new(&encoded), Some(lrecl)).unwrap();
578        let mut count = 0u64;
579        while let Some(record) = reader.read_record().unwrap() {
580            assert_eq!(record.as_slice(), payload.as_slice());
581            count += 1;
582        }
583        assert_eq!(count, record_count);
584        assert_eq!(reader.record_count(), record_count);
585    }
586}