copybook_codec/iterator.rs
1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Record iterator for streaming access to decoded records
3//!
4//! This module provides iterator-based access to records for programmatic processing,
5//! allowing users to process records one at a time without loading entire files into memory.
6//!
7//! # Overview
8//!
9//! The iterator module implements streaming record processing with bounded memory usage.
10//! It provides low-level iterator primitives for reading COBOL data files sequentially,
11//! supporting both fixed-length and RDW (Record Descriptor Word) variable-length formats.
12//!
13//! Key capabilities:
14//!
15//! 1. **Streaming iteration** ([`RecordIterator`]) - Process records one at a time
16//! 2. **Format flexibility** - Handle both fixed-length and RDW variable-length records
17//! 3. **Raw access** ([`RecordIterator::read_raw_record`]) - Access undecoded record bytes
18//! 4. **Convenience functions** ([`iter_records_from_file`], [`iter_records`]) - Simplified creation
19//!
20//! # Performance Characteristics
21//!
22//! The iterator uses buffered I/O and maintains bounded memory usage:
23//! - **Memory**: One record buffer (typically <32 KiB per record)
24//! - **Throughput**: Depends on decode complexity (DISPLAY vs COMP-3)
25//! - **Latency**: Sequential I/O optimized with `BufReader`
26//!
27//! For high-throughput parallel processing, consider using [`crate::decode_file_to_jsonl`]
28//! which provides parallel worker pools and streaming output.
29//!
30//! # Examples
31//!
32//! ## Basic Fixed-Length Record Iteration
33//!
34//! ```rust
35//! use copybook_codec::{iter_records_from_file, DecodeOptions, Codepage, RecordFormat};
36//! use copybook_core::parse_copybook;
37//!
38//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
39//! // Parse copybook schema
40//! let copybook_text = r#"
41//! 01 CUSTOMER-RECORD.
42//! 05 CUSTOMER-ID PIC 9(5).
43//! 05 CUSTOMER-NAME PIC X(20).
44//! 05 BALANCE PIC S9(7)V99 COMP-3.
45//! "#;
46//! let schema = parse_copybook(copybook_text)?;
47//!
48//! // Configure decoding options
49//! let options = DecodeOptions::new()
50//! .with_codepage(Codepage::CP037)
51//! .with_format(RecordFormat::Fixed);
52//!
53//! // Create iterator from file
54//! # #[cfg(not(test))]
55//! let iterator = iter_records_from_file("customers.bin", &schema, &options)?;
56//!
57//! // Process records one at a time
58//! # #[cfg(not(test))]
59//! for (index, result) in iterator.enumerate() {
60//! match result {
61//! Ok(json_value) => {
62//! println!("Record {}: {}", index + 1, json_value);
63//! }
64//! Err(error) => {
65//! eprintln!("Error in record {}: {}", index + 1, error);
66//! break; // Stop on first error
67//! }
68//! }
69//! }
70//! # Ok(())
71//! # }
72//! ```
73//!
74//! ## RDW Variable-Length Records
75//!
76//! ```rust
77//! use copybook_codec::{RecordIterator, DecodeOptions, RecordFormat};
78//! use copybook_core::parse_copybook;
79//! use std::fs::File;
80//!
81//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
82//! let copybook_text = r#"
83//! 01 TRANSACTION.
84//! 05 TRAN-ID PIC 9(10).
85//! 05 TRAN-AMOUNT PIC S9(9)V99 COMP-3.
86//! 05 TRAN-DESC PIC X(100).
87//! "#;
88//! let schema = parse_copybook(copybook_text)?;
89//!
90//! let options = DecodeOptions::new()
91//! .with_format(RecordFormat::RDW); // RDW variable-length format
92//!
93//! # #[cfg(not(test))]
94//! let file = File::open("transactions.dat")?;
95//! # #[cfg(test)]
96//! # let file = std::io::Cursor::new(vec![]);
97//! let mut iterator = RecordIterator::new(file, &schema, &options)?;
98//!
99//! // Process with error recovery
100//! let mut processed = 0;
101//! let mut errors = 0;
102//!
103//! for (index, result) in iterator.enumerate() {
104//! match result {
105//! Ok(json_value) => {
106//! processed += 1;
107//! // Process record...
108//! }
109//! Err(error) => {
110//! errors += 1;
111//! eprintln!("Record {}: {}", index + 1, error);
112//!
113//! if errors > 10 {
114//! eprintln!("Too many errors, stopping");
115//! break;
116//! }
117//! }
118//! }
119//! }
120//!
121//! println!("Processed: {}, Errors: {}", processed, errors);
122//! # Ok(())
123//! # }
124//! ```
125//!
126//! ## Raw Record Access (No Decoding)
127//!
128//! ```rust
129//! use copybook_codec::{RecordIterator, DecodeOptions, RecordFormat};
130//! use copybook_core::parse_copybook;
131//! use std::io::Cursor;
132//!
133//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
134//! let copybook_text = "01 RECORD.\n 05 DATA PIC X(10).";
135//! let schema = parse_copybook(copybook_text)?;
136//!
137//! let options = DecodeOptions::new()
138//! .with_format(RecordFormat::Fixed);
139//!
140//! let data = b"RECORD0001RECORD0002";
141//! let mut iterator = RecordIterator::new(Cursor::new(data), &schema, &options)?;
142//!
143//! // Read raw bytes without JSON decoding
144//! while let Some(raw_bytes) = iterator.read_raw_record()? {
145//! println!("Raw record {}: {} bytes",
146//! iterator.current_record_index(),
147//! raw_bytes.len());
148//!
149//! // Process raw bytes directly...
150//! // (useful for binary analysis, checksums, etc.)
151//! }
152//! # Ok(())
153//! # }
154//! ```
155//!
156//! ## Collecting Records into a Vec
157//!
158//! ```rust
159//! use copybook_codec::{iter_records, DecodeOptions};
160//! use copybook_core::parse_copybook;
161//! use serde_json::Value;
162//! use std::io::Cursor;
163//!
164//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
165//! let copybook_text = "01 RECORD.\n 05 ID PIC 9(5).";
166//! let schema = parse_copybook(copybook_text)?;
167//! let options = DecodeOptions::default();
168//!
169//! let data = b"0000100002";
170//! let iterator = iter_records(Cursor::new(data), &schema, &options)?;
171//!
172//! // Collect all successful records
173//! let records: Vec<Value> = iterator
174//! .filter_map(Result::ok) // Skip errors
175//! .collect();
176//!
177//! println!("Collected {} records", records.len());
178//! # Ok(())
179//! # }
180//! ```
181//!
182//! ## Using with `DecodeOptions` and Metadata
183//!
184//! ```rust
185//! use copybook_codec::{iter_records_from_file, DecodeOptions, Codepage, JsonNumberMode};
186//! use copybook_core::parse_copybook;
187//!
188//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
189//! let copybook_text = r#"
190//! 01 RECORD.
191//! 05 AMOUNT PIC S9(9)V99 COMP-3.
192//! "#;
193//! let schema = parse_copybook(copybook_text)?;
194//!
195//! // Configure with lossless numbers and metadata
196//! let options = DecodeOptions::new()
197//! .with_codepage(Codepage::CP037)
198//! .with_json_number_mode(JsonNumberMode::Lossless)
199//! .with_emit_meta(true); // Include field metadata
200//!
201//! # #[cfg(not(test))]
202//! let iterator = iter_records_from_file("data.bin", &schema, &options)?;
203//!
204//! # #[cfg(not(test))]
205//! for result in iterator {
206//! let json_value = result?;
207//! // JSON includes metadata: {"AMOUNT": "123.45", "_meta": {...}}
208//! println!("{}", serde_json::to_string_pretty(&json_value)?);
209//! }
210//! # Ok(())
211//! # }
212//! ```
213
214use crate::options::{DecodeOptions, RecordFormat};
215use copybook_core::{Error, ErrorCode, Result, Schema};
216use copybook_rdw::RdwHeader;
217use serde_json::Value;
218use std::io::{BufReader, Read};
219
220const FIXED_FORMAT_LRECL_MISSING: &str = "Fixed format requires a fixed record length (LRECL). \
221 Set `schema.lrecl_fixed` or use `RecordFormat::Variable`.";
222
223/// Iterator over records in a data file, yielding decoded JSON values
224///
225/// This iterator provides streaming access to records, processing them one at a time
226/// to maintain bounded memory usage even for very large files.
227///
228/// # Examples
229///
230/// ```rust,no_run
231/// use copybook_codec::{RecordIterator, DecodeOptions};
232/// use copybook_core::parse_copybook;
233/// # use std::io::Cursor;
234///
235/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
236/// let copybook_text = "01 RECORD.\n 05 ID PIC 9(5).\n 05 NAME PIC X(20).";
237/// let mut schema = parse_copybook(copybook_text)?;
238/// schema.lrecl_fixed = Some(25);
239/// let options = DecodeOptions::default();
240/// # let record_bytes = b"00001ALICE ";
241/// # let file = Cursor::new(&record_bytes[..]);
242/// // let file = std::fs::File::open("data.bin")?;
243///
244/// let mut iterator = RecordIterator::new(file, &schema, &options)?;
245///
246/// for (record_index, result) in iterator.enumerate() {
247/// match result {
248/// Ok(json_value) => {
249/// println!("Record {}: {}", record_index + 1, json_value);
250/// }
251/// Err(error) => {
252/// eprintln!("Error in record {}: {}", record_index + 1, error);
253/// }
254/// }
255/// }
256/// # Ok(())
257/// # }
258/// ```
259pub struct RecordIterator<R: Read> {
260 /// The buffered reader
261 reader: BufReader<R>,
262 /// The schema for decoding records
263 schema: Schema,
264 /// Decoding options
265 options: DecodeOptions,
266 /// Current record index (1-based)
267 record_index: u64,
268 /// Whether the iterator has reached EOF
269 eof_reached: bool,
270 /// Buffer for reading record data
271 buffer: Vec<u8>,
272}
273
274impl<R: Read> RecordIterator<R> {
275 /// Create a new record iterator
276 ///
277 /// # Arguments
278 ///
279 /// * `reader` - The input stream to read from
280 /// * `schema` - The parsed copybook schema
281 /// * `options` - Decoding options
282 ///
283 /// # Errors
284 /// Returns an error if the record format is incompatible with the schema.
285 #[inline]
286 #[must_use = "Handle the Result or propagate the error"]
287 pub fn new(reader: R, schema: &Schema, options: &DecodeOptions) -> Result<Self> {
288 Ok(Self {
289 reader: BufReader::new(reader),
290 schema: schema.clone(),
291 options: options.clone(),
292 record_index: 0,
293 eof_reached: false,
294 buffer: Vec::new(),
295 })
296 }
297
298 /// Get the current record index (1-based)
299 ///
300 /// This returns the index of the last record that was successfully read,
301 /// or 0 if no records have been read yet.
302 #[inline]
303 #[must_use]
304 pub fn current_record_index(&self) -> u64 {
305 self.record_index
306 }
307
308 /// Check if the iterator has reached the end of the file
309 #[inline]
310 #[must_use]
311 pub fn is_eof(&self) -> bool {
312 self.eof_reached
313 }
314
315 /// Get a reference to the schema being used
316 #[inline]
317 #[must_use]
318 pub fn schema(&self) -> &Schema {
319 &self.schema
320 }
321
322 /// Get a reference to the decode options being used
323 #[inline]
324 #[must_use]
325 pub fn options(&self) -> &DecodeOptions {
326 &self.options
327 }
328
329 /// Read the next record without decoding it
330 ///
331 /// This method reads the raw bytes of the next record without performing
332 /// JSON decoding. Useful for applications that need access to raw record data
333 /// for binary analysis, checksums, or custom processing.
334 ///
335 /// # Returns
336 ///
337 /// * `Ok(Some(bytes))` - The raw record bytes
338 /// * `Ok(None)` - End of file reached
339 /// * `Err(error)` - An error occurred while reading
340 ///
341 /// # Errors
342 /// Returns an error if underlying I/O operations fail or the record format is invalid.
343 ///
344 /// # Examples
345 ///
346 /// ```rust
347 /// use copybook_codec::{RecordIterator, DecodeOptions, RecordFormat};
348 /// use copybook_core::parse_copybook;
349 /// use std::io::Cursor;
350 ///
351 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
352 /// let copybook_text = "01 RECORD.\n 05 DATA PIC X(8).";
353 /// let schema = parse_copybook(copybook_text)?;
354 ///
355 /// let options = DecodeOptions::new()
356 /// .with_format(RecordFormat::Fixed);
357 ///
358 /// let data = b"RECORD01RECORD02";
359 /// let mut iterator = RecordIterator::new(Cursor::new(data), &schema, &options)?;
360 ///
361 /// // Read raw bytes
362 /// if let Some(raw_bytes) = iterator.read_raw_record()? {
363 /// assert_eq!(raw_bytes, b"RECORD01");
364 /// assert_eq!(iterator.current_record_index(), 1);
365 /// }
366 ///
367 /// if let Some(raw_bytes) = iterator.read_raw_record()? {
368 /// assert_eq!(raw_bytes, b"RECORD02");
369 /// assert_eq!(iterator.current_record_index(), 2);
370 /// }
371 ///
372 /// // End of file
373 /// assert!(iterator.read_raw_record()?.is_none());
374 /// assert!(iterator.is_eof());
375 /// # Ok(())
376 /// # }
377 /// ```
378 #[inline]
379 #[must_use = "Handle the Result or propagate the error"]
380 pub fn read_raw_record(&mut self) -> Result<Option<Vec<u8>>> {
381 if self.eof_reached {
382 return Ok(None);
383 }
384
385 self.buffer.clear();
386
387 let record_data = match self.options.format {
388 RecordFormat::Fixed => {
389 let lrecl = self.schema.lrecl_fixed.ok_or_else(|| {
390 Error::new(ErrorCode::CBKI001_INVALID_STATE, FIXED_FORMAT_LRECL_MISSING)
391 })? as usize;
392 self.buffer.resize(lrecl, 0);
393
394 match self.reader.read_exact(&mut self.buffer) {
395 Ok(()) => {
396 self.record_index += 1;
397 Some(self.buffer.clone())
398 }
399 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
400 self.eof_reached = true;
401 return Ok(None);
402 }
403 Err(e) => {
404 return Err(Error::new(
405 ErrorCode::CBKR101_FIXED_RECORD_ERROR,
406 format!("Failed to read fixed record: {e}"),
407 ));
408 }
409 }
410 }
411 RecordFormat::RDW => {
412 // Read RDW header
413 let mut rdw_header = [0u8; 4];
414 match self.reader.read_exact(&mut rdw_header) {
415 Ok(()) => {}
416 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
417 self.eof_reached = true;
418 return Ok(None);
419 }
420 Err(e) => {
421 return Err(Error::new(
422 ErrorCode::CBKF221_RDW_UNDERFLOW,
423 format!("Failed to read RDW header: {e}"),
424 ));
425 }
426 }
427
428 // Parse length (payload bytes only)
429 let length = usize::from(RdwHeader::from_bytes(rdw_header).length());
430
431 // Read payload
432 self.buffer.resize(length, 0);
433 match self.reader.read_exact(&mut self.buffer) {
434 Ok(()) => {
435 self.record_index += 1;
436 Some(self.buffer.clone())
437 }
438 Err(e) => {
439 return Err(Error::new(
440 ErrorCode::CBKF221_RDW_UNDERFLOW,
441 format!("Failed to read RDW payload: {e}"),
442 ));
443 }
444 }
445 }
446 };
447
448 Ok(record_data)
449 }
450
451 /// Decode the next record to JSON
452 ///
453 /// This is the main method used by the Iterator implementation.
454 /// It reads and decodes the next record in one operation.
455 #[inline]
456 fn decode_next_record(&mut self) -> Result<Option<Value>> {
457 match self.read_raw_record()? {
458 Some(record_bytes) => {
459 let json_value = crate::decode_record(&self.schema, &record_bytes, &self.options)?;
460 Ok(Some(json_value))
461 }
462 None => Ok(None),
463 }
464 }
465}
466
467impl<R: Read> Iterator for RecordIterator<R> {
468 type Item = Result<Value>;
469
470 #[inline]
471 fn next(&mut self) -> Option<Self::Item> {
472 if self.eof_reached {
473 return None;
474 }
475
476 match self.decode_next_record() {
477 Ok(Some(value)) => Some(Ok(value)),
478 Ok(None) => {
479 self.eof_reached = true;
480 None
481 }
482 Err(error) => {
483 // On error, we still advance the record index if we were able to read something
484 Some(Err(error))
485 }
486 }
487 }
488}
489
490/// Convenience function to create a record iterator from a file path
491///
492/// This is the most common way to create an iterator for processing COBOL data files.
493/// It handles file opening and iterator creation in a single call.
494///
495/// # Arguments
496///
497/// * `file_path` - Path to the data file
498/// * `schema` - The parsed copybook schema
499/// * `options` - Decoding options
500///
501/// # Errors
502/// Returns an error if the file cannot be opened or the iterator cannot be created.
503///
504/// # Examples
505///
506/// ## Basic Usage with Fixed-Length Records
507///
508/// ```rust,no_run
509/// use copybook_codec::{iter_records_from_file, DecodeOptions, Codepage, RecordFormat};
510/// use copybook_core::parse_copybook;
511///
512/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
513/// let copybook_text = r#"
514/// 01 EMPLOYEE-RECORD.
515/// 05 EMP-ID PIC 9(6).
516/// 05 EMP-NAME PIC X(30).
517/// 05 EMP-SALARY PIC S9(7)V99 COMP-3.
518/// "#;
519/// let schema = parse_copybook(copybook_text)?;
520///
521/// let options = DecodeOptions::new()
522/// .with_codepage(Codepage::CP037)
523/// .with_format(RecordFormat::Fixed);
524///
525/// let iterator = iter_records_from_file("employees.dat", &schema, &options)?;
526///
527/// for (index, result) in iterator.enumerate() {
528/// match result {
529/// Ok(employee) => println!("Employee {}: {}", index + 1, employee),
530/// Err(e) => eprintln!("Error at record {}: {}", index + 1, e),
531/// }
532/// }
533/// # Ok(())
534/// # }
535/// ```
536///
537/// ## Processing with Error Limits
538///
539/// ```rust,no_run
540/// use copybook_codec::{iter_records_from_file, DecodeOptions};
541/// use copybook_core::parse_copybook;
542///
543/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
544/// # let schema = parse_copybook("01 R.\n 05 F PIC X(1).")?;
545/// # let options = DecodeOptions::default();
546/// let iterator = iter_records_from_file("data.bin", &schema, &options)?;
547///
548/// let mut success_count = 0;
549/// let mut error_count = 0;
550/// const MAX_ERRORS: usize = 100;
551///
552/// for result in iterator {
553/// match result {
554/// Ok(_) => success_count += 1,
555/// Err(e) => {
556/// error_count += 1;
557/// eprintln!("Error: {}", e);
558///
559/// if error_count >= MAX_ERRORS {
560/// eprintln!("Too many errors, aborting");
561/// break;
562/// }
563/// }
564/// }
565/// }
566///
567/// println!("Success: {}, Errors: {}", success_count, error_count);
568/// # Ok(())
569/// # }
570/// ```
571#[inline]
572#[must_use = "Handle the Result or propagate the error"]
573pub fn iter_records_from_file<P: AsRef<std::path::Path>>(
574 file_path: P,
575 schema: &Schema,
576 options: &DecodeOptions,
577) -> Result<RecordIterator<std::fs::File>> {
578 let file = std::fs::File::open(file_path).map_err(|e| {
579 Error::new(
580 ErrorCode::CBKR201_RDW_READ_ERROR,
581 format!("failed to open input file: {e}"),
582 )
583 })?;
584
585 RecordIterator::new(file, schema, options)
586}
587
588/// Convenience function to create a record iterator from any readable source
589///
590/// This function provides maximum flexibility by accepting any type that implements
591/// the `Read` trait, including files, cursors, network streams, or custom readers.
592///
593/// # Arguments
594///
595/// * `reader` - Any type implementing Read (File, Cursor, `TcpStream`, etc.)
596/// * `schema` - The parsed copybook schema
597/// * `options` - Decoding options
598///
599/// # Errors
600/// Returns an error if the iterator cannot be created.
601///
602/// # Examples
603///
604/// ## Using with In-Memory Data (Cursor)
605///
606/// ```rust
607/// use copybook_codec::{iter_records, DecodeOptions, RecordFormat};
608/// use copybook_core::parse_copybook;
609/// use std::io::Cursor;
610///
611/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
612/// let copybook_text = "01 RECORD.\n 05 ID PIC 9(3).\n 05 NAME PIC X(5).";
613/// let schema = parse_copybook(copybook_text)?;
614///
615/// let options = DecodeOptions::new()
616/// .with_format(RecordFormat::Fixed);
617///
618/// // Create iterator from in-memory data
619/// let data = b"001ALICE002BOB 003CAROL";
620/// let iterator = iter_records(Cursor::new(data), &schema, &options)?;
621///
622/// let records: Vec<_> = iterator.collect::<Result<Vec<_>, _>>()?;
623/// assert_eq!(records.len(), 3);
624/// # Ok(())
625/// # }
626/// ```
627///
628/// ## Using with File
629///
630/// ```rust,no_run
631/// use copybook_codec::{iter_records, DecodeOptions};
632/// use copybook_core::parse_copybook;
633/// use std::fs::File;
634///
635/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
636/// let schema = parse_copybook("01 RECORD.\n 05 DATA PIC X(10).")?;
637/// let options = DecodeOptions::default();
638///
639/// let file = File::open("data.bin")?;
640/// let iterator = iter_records(file, &schema, &options)?;
641///
642/// for result in iterator {
643/// let record = result?;
644/// println!("{}", record);
645/// }
646/// # Ok(())
647/// # }
648/// ```
649///
650/// ## Using with Compressed Data
651///
652/// ```text
653/// use copybook_codec::{iter_records, DecodeOptions};
654/// use copybook_core::parse_copybook;
655/// use std::fs::File;
656/// use flate2::read::GzDecoder;
657///
658/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
659/// let schema = parse_copybook("01 RECORD.\n 05 DATA PIC X(10).")?;
660/// let options = DecodeOptions::default();
661///
662/// // Read from gzipped file
663/// let file = File::open("data.bin.gz")?;
664/// let decoder = GzDecoder::new(file);
665/// let iterator = iter_records(decoder, &schema, &options)?;
666///
667/// for result in iterator {
668/// let record = result?;
669/// // Process decompressed record...
670/// }
671/// # Ok(())
672/// # }
673/// ```
674#[inline]
675#[must_use = "Handle the Result or propagate the error"]
676pub fn iter_records<R: Read>(
677 reader: R,
678 schema: &Schema,
679 options: &DecodeOptions,
680) -> Result<RecordIterator<R>> {
681 RecordIterator::new(reader, schema, options)
682}
683
684#[cfg(test)]
685#[allow(clippy::expect_used)]
686#[allow(clippy::unwrap_used)]
687#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
688mod tests {
689 use super::*;
690 use crate::Codepage;
691 use copybook_core::parse_copybook;
692 use std::io::{self, Cursor, Read};
693
694 #[test]
695 fn test_record_iterator_basic() {
696 let copybook_text = r"
697 01 RECORD.
698 05 ID PIC 9(3).
699 05 NAME PIC X(5).
700 ";
701
702 let schema = parse_copybook(copybook_text).unwrap();
703
704 // Create test data: two 8-byte fixed records
705 let test_data = b"001ALICE002BOB ";
706 let cursor = Cursor::new(test_data);
707
708 let options = DecodeOptions {
709 format: RecordFormat::Fixed,
710 ..DecodeOptions::default()
711 };
712
713 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
714
715 // Just test that the iterator can be created successfully
716 assert_eq!(iterator.current_record_index(), 0);
717 assert!(!iterator.is_eof());
718 }
719
720 #[test]
721 fn test_record_iterator_rdw() {
722 let copybook_text = r"
723 01 RECORD.
724 05 ID PIC 9(3).
725 05 NAME PIC X(5).
726 ";
727
728 let schema = parse_copybook(copybook_text).unwrap();
729
730 // Create RDW test data:
731 // Record 1: length=8, reserved=0, data="001ALICE"
732 // Record 2: length=6, reserved=0, data="002BOB"
733 let test_data = vec![
734 0x00, 0x08, 0x00, 0x00, // RDW header: length=8, reserved=0
735 b'0', b'0', b'1', b'A', b'L', b'I', b'C', b'E', // Record 1 data
736 0x00, 0x06, 0x00, 0x00, // RDW header: length=6, reserved=0
737 b'0', b'0', b'2', b'B', b'O', b'B', // Record 2 data
738 ];
739
740 let cursor = Cursor::new(test_data);
741
742 let options = DecodeOptions {
743 format: RecordFormat::RDW,
744 ..DecodeOptions::default()
745 };
746
747 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
748
749 // Just test that the iterator can be created successfully
750 assert_eq!(iterator.current_record_index(), 0);
751 assert!(!iterator.is_eof());
752 }
753
754 #[test]
755 fn test_raw_record_reading() {
756 let copybook_text = r"
757 01 RECORD.
758 05 ID PIC 9(3).
759 05 NAME PIC X(5).
760 ";
761
762 let schema = parse_copybook(copybook_text).unwrap();
763
764 let test_data = b"001ALICE";
765 let cursor = Cursor::new(test_data);
766
767 let options = DecodeOptions {
768 format: RecordFormat::Fixed,
769 ..DecodeOptions::default()
770 };
771
772 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
773
774 // Read raw record
775 let raw_record = iterator.read_raw_record().unwrap().unwrap();
776 assert_eq!(raw_record, b"001ALICE");
777 assert_eq!(iterator.current_record_index(), 1);
778
779 // End of file
780 assert!(iterator.read_raw_record().unwrap().is_none());
781 }
782
783 #[test]
784 fn test_iterator_error_handling() {
785 let copybook_text = r"
786 01 RECORD.
787 05 ID PIC 9(3).
788 05 NAME PIC X(5).
789 ";
790
791 let schema = parse_copybook(copybook_text).unwrap();
792
793 // Create incomplete record (only 4 bytes instead of 8)
794 let test_data = b"001A";
795 let cursor = Cursor::new(test_data);
796
797 let options = DecodeOptions {
798 format: RecordFormat::Fixed,
799 ..DecodeOptions::default()
800 };
801
802 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
803
804 // Should yield EOF (Ok(None)) when encountering truncated fixed-length data
805 assert!(iterator.next().is_none());
806 }
807
808 #[test]
809 fn test_iterator_fixed_format_missing_lrecl_errors_on_next() {
810 // A schema without a fixed record length
811 let copybook_text = "01 SOME-GROUP. 05 SOME-FIELD PIC X(1).";
812 let mut schema = parse_copybook(copybook_text).unwrap();
813 schema.lrecl_fixed = None; // Ensure it's None
814
815 let test_data = b"";
816 let cursor = Cursor::new(test_data);
817
818 let options = DecodeOptions {
819 format: RecordFormat::Fixed,
820 ..DecodeOptions::default()
821 };
822
823 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
824
825 let first = iterator.next().unwrap();
826 assert!(first.is_err());
827 if let Err(e) = first {
828 assert_eq!(e.code, ErrorCode::CBKI001_INVALID_STATE);
829 assert_eq!(e.message, FIXED_FORMAT_LRECL_MISSING);
830 }
831 }
832
833 #[test]
834 fn test_iterator_schema_and_options_accessors() {
835 let copybook_text = r"
836 01 RECORD.
837 05 ID PIC 9(3).
838 05 NAME PIC X(5).
839 ";
840
841 let mut schema = parse_copybook(copybook_text).unwrap();
842 schema.lrecl_fixed = Some(8);
843 let test_data = b"001ALICE";
844 let cursor = Cursor::new(test_data);
845
846 let options = DecodeOptions {
847 format: RecordFormat::Fixed,
848 codepage: Codepage::ASCII,
849 ..DecodeOptions::default()
850 };
851
852 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
853
854 // Test schema accessor
855 assert_eq!(iterator.schema().fields[0].name, "RECORD");
856
857 // Test options accessor
858 assert_eq!(iterator.options().format, RecordFormat::Fixed);
859 }
860
861 #[test]
862 fn test_iterator_multiple_fixed_records() {
863 let copybook_text = r"
864 01 RECORD.
865 05 ID PIC 9(3).
866 05 NAME PIC X(5).
867 ";
868
869 let mut schema = parse_copybook(copybook_text).unwrap();
870 schema.lrecl_fixed = Some(8);
871
872 // Create test data: three 8-byte fixed records
873 let test_data = b"001ALICE002BOB 003CAROL";
874 let cursor = Cursor::new(test_data);
875
876 let options = DecodeOptions {
877 format: RecordFormat::Fixed,
878 codepage: Codepage::ASCII,
879 ..DecodeOptions::default()
880 };
881
882 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
883
884 // Read all records
885 let mut count = 0;
886 for result in iterator.by_ref() {
887 assert!(result.is_ok(), "Record {count} should decode successfully");
888 count += 1;
889 }
890
891 assert_eq!(count, 3);
892 assert_eq!(iterator.current_record_index(), 3);
893 assert!(iterator.is_eof());
894 }
895
896 #[test]
897 fn test_iterator_rdw_multiple_records() {
898 let copybook_text = r"
899 01 RECORD.
900 05 ID PIC 9(3).
901 05 NAME PIC X(5).
902 ";
903
904 let schema = parse_copybook(copybook_text).unwrap();
905
906 // Create RDW test data with three records
907 let test_data = vec![
908 // Record 1
909 0x00, 0x08, 0x00, 0x00, // RDW header: length=8
910 b'0', b'0', b'1', b'A', b'L', b'I', b'C', b'E', // Record 2
911 0x00, 0x06, 0x00, 0x00, // RDW header: length=6
912 b'0', b'0', b'2', b'B', b'O', b'B', // Record 3
913 0x00, 0x08, 0x00, 0x00, // RDW header: length=8
914 b'0', b'0', b'3', b'C', b'A', b'R', b'O', b'L',
915 ];
916
917 let cursor = Cursor::new(test_data);
918
919 let options = DecodeOptions {
920 format: RecordFormat::RDW,
921 codepage: Codepage::ASCII,
922 ..DecodeOptions::default()
923 };
924
925 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
926
927 // Read all records
928 let mut count = 0;
929 for result in iterator.by_ref() {
930 assert!(result.is_ok(), "Record {count} should decode successfully");
931 count += 1;
932 }
933
934 assert_eq!(count, 3);
935 assert_eq!(iterator.current_record_index(), 3);
936 assert!(iterator.is_eof());
937 }
938
939 #[test]
940 fn test_iter_records_convenience() {
941 let copybook_text = r"
942 01 RECORD.
943 05 ID PIC 9(3).
944 05 NAME PIC X(5).
945 ";
946
947 let schema = parse_copybook(copybook_text).unwrap();
948
949 let test_data = b"001ALICE002BOB ";
950 let cursor = Cursor::new(test_data);
951
952 let options = DecodeOptions {
953 format: RecordFormat::Fixed,
954 ..DecodeOptions::default()
955 };
956
957 let iterator = iter_records(cursor, &schema, &options).unwrap();
958
959 assert_eq!(iterator.current_record_index(), 0);
960 assert!(!iterator.is_eof());
961 }
962
963 #[test]
964 fn test_iterator_with_empty_data() {
965 let copybook_text = r"
966 01 RECORD.
967 05 ID PIC 9(3).
968 05 NAME PIC X(5).
969 ";
970
971 let mut schema = parse_copybook(copybook_text).unwrap();
972 schema.lrecl_fixed = Some(8);
973
974 let test_data = b"";
975 let cursor = Cursor::new(test_data);
976
977 let options = DecodeOptions {
978 format: RecordFormat::Fixed,
979 ..DecodeOptions::default()
980 };
981
982 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
983
984 // Should immediately return None for empty data
985 assert!(iterator.next().is_none());
986 assert!(iterator.is_eof());
987 assert_eq!(iterator.current_record_index(), 0);
988 }
989
990 #[test]
991 fn test_iterator_raw_record_eof() {
992 let copybook_text = r"
993 01 RECORD.
994 05 ID PIC 9(3).
995 05 NAME PIC X(5).
996 ";
997
998 let schema = parse_copybook(copybook_text).unwrap();
999
1000 let test_data = b"001ALICE";
1001 let cursor = Cursor::new(test_data);
1002
1003 let options = DecodeOptions {
1004 format: RecordFormat::Fixed,
1005 ..DecodeOptions::default()
1006 };
1007
1008 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
1009
1010 // Read first record
1011 assert!(iterator.read_raw_record().unwrap().is_some());
1012 assert_eq!(iterator.current_record_index(), 1);
1013
1014 // Read second record (should be None)
1015 assert!(iterator.read_raw_record().unwrap().is_none());
1016 assert!(iterator.is_eof());
1017 }
1018
1019 #[test]
1020 fn test_iterator_collect_results() {
1021 let copybook_text = r"
1022 01 RECORD.
1023 05 ID PIC 9(3).
1024 05 NAME PIC X(5).
1025 ";
1026
1027 let mut schema = parse_copybook(copybook_text).unwrap();
1028 schema.lrecl_fixed = Some(8);
1029
1030 let test_data = b"001ALICE002BOB 003CAROL";
1031 let cursor = Cursor::new(test_data);
1032
1033 let options = DecodeOptions {
1034 format: RecordFormat::Fixed,
1035 codepage: Codepage::ASCII,
1036 ..DecodeOptions::default()
1037 };
1038
1039 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
1040
1041 // Collect all results
1042 let results: Vec<Result<Value>> = iterator.collect();
1043
1044 assert_eq!(results.len(), 3);
1045 for result in results {
1046 assert!(result.is_ok());
1047 }
1048 }
1049
1050 #[test]
1051 fn test_iterator_with_decode_error() {
1052 let copybook_text = r"
1053 01 RECORD.
1054 05 ID PIC 9(3).
1055 05 NAME PIC X(5).
1056 ";
1057
1058 let mut schema = parse_copybook(copybook_text).unwrap();
1059 schema.lrecl_fixed = Some(8);
1060
1061 // Create data that will decode successfully for first record
1062 let test_data = b"001ALICE";
1063 let cursor = Cursor::new(test_data);
1064
1065 let options = DecodeOptions {
1066 format: RecordFormat::Fixed,
1067 codepage: Codepage::ASCII,
1068 ..DecodeOptions::default()
1069 };
1070
1071 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
1072
1073 // First record should decode successfully
1074 let first = iterator.next();
1075 assert!(first.is_some());
1076 assert!(first.unwrap().is_ok());
1077
1078 // Second call should return None (EOF)
1079 assert!(iterator.next().is_none());
1080 }
1081
1082 #[derive(Default)]
1083 struct FailingReader {
1084 fail: bool,
1085 }
1086
1087 impl Read for FailingReader {
1088 fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
1089 if self.fail {
1090 Ok(0)
1091 } else {
1092 self.fail = true;
1093 Err(io::Error::other("forced read error"))
1094 }
1095 }
1096 }
1097
1098 #[test]
1099 fn test_iterator_fixed_format_read_error_code() {
1100 let copybook_text = r"
1101 01 RECORD.
1102 05 ID PIC 9(3).
1103 05 NAME PIC X(5).
1104 ";
1105
1106 let schema = parse_copybook(copybook_text).unwrap();
1107
1108 let mut schema = schema;
1109 schema.lrecl_fixed = Some(8);
1110
1111 let mut iterator =
1112 RecordIterator::new(FailingReader::default(), &schema, &DecodeOptions::default())
1113 .unwrap();
1114
1115 let error = iterator.read_raw_record().unwrap_err();
1116 assert_eq!(error.code, ErrorCode::CBKR101_FIXED_RECORD_ERROR);
1117 }
1118}