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::CBKD301_RECORD_TOO_SHORT,
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)
579 .map_err(|e| Error::new(ErrorCode::CBKF104_RDW_SUSPECT_ASCII, e.to_string()))?;
580
581 RecordIterator::new(file, schema, options)
582}
583
584/// Convenience function to create a record iterator from any readable source
585///
586/// This function provides maximum flexibility by accepting any type that implements
587/// the `Read` trait, including files, cursors, network streams, or custom readers.
588///
589/// # Arguments
590///
591/// * `reader` - Any type implementing Read (File, Cursor, `TcpStream`, etc.)
592/// * `schema` - The parsed copybook schema
593/// * `options` - Decoding options
594///
595/// # Errors
596/// Returns an error if the iterator cannot be created.
597///
598/// # Examples
599///
600/// ## Using with In-Memory Data (Cursor)
601///
602/// ```rust
603/// use copybook_codec::{iter_records, DecodeOptions, RecordFormat};
604/// use copybook_core::parse_copybook;
605/// use std::io::Cursor;
606///
607/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
608/// let copybook_text = "01 RECORD.\n 05 ID PIC 9(3).\n 05 NAME PIC X(5).";
609/// let schema = parse_copybook(copybook_text)?;
610///
611/// let options = DecodeOptions::new()
612/// .with_format(RecordFormat::Fixed);
613///
614/// // Create iterator from in-memory data
615/// let data = b"001ALICE002BOB 003CAROL";
616/// let iterator = iter_records(Cursor::new(data), &schema, &options)?;
617///
618/// let records: Vec<_> = iterator.collect::<Result<Vec<_>, _>>()?;
619/// assert_eq!(records.len(), 3);
620/// # Ok(())
621/// # }
622/// ```
623///
624/// ## Using with File
625///
626/// ```rust,no_run
627/// use copybook_codec::{iter_records, DecodeOptions};
628/// use copybook_core::parse_copybook;
629/// use std::fs::File;
630///
631/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
632/// let schema = parse_copybook("01 RECORD.\n 05 DATA PIC X(10).")?;
633/// let options = DecodeOptions::default();
634///
635/// let file = File::open("data.bin")?;
636/// let iterator = iter_records(file, &schema, &options)?;
637///
638/// for result in iterator {
639/// let record = result?;
640/// println!("{}", record);
641/// }
642/// # Ok(())
643/// # }
644/// ```
645///
646/// ## Using with Compressed Data
647///
648/// ```text
649/// use copybook_codec::{iter_records, DecodeOptions};
650/// use copybook_core::parse_copybook;
651/// use std::fs::File;
652/// use flate2::read::GzDecoder;
653///
654/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
655/// let schema = parse_copybook("01 RECORD.\n 05 DATA PIC X(10).")?;
656/// let options = DecodeOptions::default();
657///
658/// // Read from gzipped file
659/// let file = File::open("data.bin.gz")?;
660/// let decoder = GzDecoder::new(file);
661/// let iterator = iter_records(decoder, &schema, &options)?;
662///
663/// for result in iterator {
664/// let record = result?;
665/// // Process decompressed record...
666/// }
667/// # Ok(())
668/// # }
669/// ```
670#[inline]
671#[must_use = "Handle the Result or propagate the error"]
672pub fn iter_records<R: Read>(
673 reader: R,
674 schema: &Schema,
675 options: &DecodeOptions,
676) -> Result<RecordIterator<R>> {
677 RecordIterator::new(reader, schema, options)
678}
679
680#[cfg(test)]
681#[allow(clippy::expect_used)]
682#[allow(clippy::unwrap_used)]
683#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
684mod tests {
685 use super::*;
686 use crate::Codepage;
687 use copybook_core::parse_copybook;
688 use std::io::Cursor;
689
690 #[test]
691 fn test_record_iterator_basic() {
692 let copybook_text = r"
693 01 RECORD.
694 05 ID PIC 9(3).
695 05 NAME PIC X(5).
696 ";
697
698 let schema = parse_copybook(copybook_text).unwrap();
699
700 // Create test data: two 8-byte fixed records
701 let test_data = b"001ALICE002BOB ";
702 let cursor = Cursor::new(test_data);
703
704 let options = DecodeOptions {
705 format: RecordFormat::Fixed,
706 ..DecodeOptions::default()
707 };
708
709 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
710
711 // Just test that the iterator can be created successfully
712 assert_eq!(iterator.current_record_index(), 0);
713 assert!(!iterator.is_eof());
714 }
715
716 #[test]
717 fn test_record_iterator_rdw() {
718 let copybook_text = r"
719 01 RECORD.
720 05 ID PIC 9(3).
721 05 NAME PIC X(5).
722 ";
723
724 let schema = parse_copybook(copybook_text).unwrap();
725
726 // Create RDW test data:
727 // Record 1: length=8, reserved=0, data="001ALICE"
728 // Record 2: length=6, reserved=0, data="002BOB"
729 let test_data = vec![
730 0x00, 0x08, 0x00, 0x00, // RDW header: length=8, reserved=0
731 b'0', b'0', b'1', b'A', b'L', b'I', b'C', b'E', // Record 1 data
732 0x00, 0x06, 0x00, 0x00, // RDW header: length=6, reserved=0
733 b'0', b'0', b'2', b'B', b'O', b'B', // Record 2 data
734 ];
735
736 let cursor = Cursor::new(test_data);
737
738 let options = DecodeOptions {
739 format: RecordFormat::RDW,
740 ..DecodeOptions::default()
741 };
742
743 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
744
745 // Just test that the iterator can be created successfully
746 assert_eq!(iterator.current_record_index(), 0);
747 assert!(!iterator.is_eof());
748 }
749
750 #[test]
751 fn test_raw_record_reading() {
752 let copybook_text = r"
753 01 RECORD.
754 05 ID PIC 9(3).
755 05 NAME PIC X(5).
756 ";
757
758 let schema = parse_copybook(copybook_text).unwrap();
759
760 let test_data = b"001ALICE";
761 let cursor = Cursor::new(test_data);
762
763 let options = DecodeOptions {
764 format: RecordFormat::Fixed,
765 ..DecodeOptions::default()
766 };
767
768 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
769
770 // Read raw record
771 let raw_record = iterator.read_raw_record().unwrap().unwrap();
772 assert_eq!(raw_record, b"001ALICE");
773 assert_eq!(iterator.current_record_index(), 1);
774
775 // End of file
776 assert!(iterator.read_raw_record().unwrap().is_none());
777 }
778
779 #[test]
780 fn test_iterator_error_handling() {
781 let copybook_text = r"
782 01 RECORD.
783 05 ID PIC 9(3).
784 05 NAME PIC X(5).
785 ";
786
787 let schema = parse_copybook(copybook_text).unwrap();
788
789 // Create incomplete record (only 4 bytes instead of 8)
790 let test_data = b"001A";
791 let cursor = Cursor::new(test_data);
792
793 let options = DecodeOptions {
794 format: RecordFormat::Fixed,
795 ..DecodeOptions::default()
796 };
797
798 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
799
800 // Should yield EOF (Ok(None)) when encountering truncated fixed-length data
801 assert!(iterator.next().is_none());
802 }
803
804 #[test]
805 fn test_iterator_fixed_format_missing_lrecl_errors_on_next() {
806 // A schema without a fixed record length
807 let copybook_text = "01 SOME-GROUP. 05 SOME-FIELD PIC X(1).";
808 let mut schema = parse_copybook(copybook_text).unwrap();
809 schema.lrecl_fixed = None; // Ensure it's None
810
811 let test_data = b"";
812 let cursor = Cursor::new(test_data);
813
814 let options = DecodeOptions {
815 format: RecordFormat::Fixed,
816 ..DecodeOptions::default()
817 };
818
819 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
820
821 let first = iterator.next().unwrap();
822 assert!(first.is_err());
823 if let Err(e) = first {
824 assert_eq!(e.code, ErrorCode::CBKI001_INVALID_STATE);
825 assert_eq!(e.message, FIXED_FORMAT_LRECL_MISSING);
826 }
827 }
828
829 #[test]
830 fn test_iterator_schema_and_options_accessors() {
831 let copybook_text = r"
832 01 RECORD.
833 05 ID PIC 9(3).
834 05 NAME PIC X(5).
835 ";
836
837 let mut schema = parse_copybook(copybook_text).unwrap();
838 schema.lrecl_fixed = Some(8);
839 let test_data = b"001ALICE";
840 let cursor = Cursor::new(test_data);
841
842 let options = DecodeOptions {
843 format: RecordFormat::Fixed,
844 codepage: Codepage::ASCII,
845 ..DecodeOptions::default()
846 };
847
848 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
849
850 // Test schema accessor
851 assert_eq!(iterator.schema().fields[0].name, "RECORD");
852
853 // Test options accessor
854 assert_eq!(iterator.options().format, RecordFormat::Fixed);
855 }
856
857 #[test]
858 fn test_iterator_multiple_fixed_records() {
859 let copybook_text = r"
860 01 RECORD.
861 05 ID PIC 9(3).
862 05 NAME PIC X(5).
863 ";
864
865 let mut schema = parse_copybook(copybook_text).unwrap();
866 schema.lrecl_fixed = Some(8);
867
868 // Create test data: three 8-byte fixed records
869 let test_data = b"001ALICE002BOB 003CAROL";
870 let cursor = Cursor::new(test_data);
871
872 let options = DecodeOptions {
873 format: RecordFormat::Fixed,
874 codepage: Codepage::ASCII,
875 ..DecodeOptions::default()
876 };
877
878 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
879
880 // Read all records
881 let mut count = 0;
882 for result in iterator.by_ref() {
883 assert!(result.is_ok(), "Record {count} should decode successfully");
884 count += 1;
885 }
886
887 assert_eq!(count, 3);
888 assert_eq!(iterator.current_record_index(), 3);
889 assert!(iterator.is_eof());
890 }
891
892 #[test]
893 fn test_iterator_rdw_multiple_records() {
894 let copybook_text = r"
895 01 RECORD.
896 05 ID PIC 9(3).
897 05 NAME PIC X(5).
898 ";
899
900 let schema = parse_copybook(copybook_text).unwrap();
901
902 // Create RDW test data with three records
903 let test_data = vec![
904 // Record 1
905 0x00, 0x08, 0x00, 0x00, // RDW header: length=8
906 b'0', b'0', b'1', b'A', b'L', b'I', b'C', b'E', // Record 2
907 0x00, 0x06, 0x00, 0x00, // RDW header: length=6
908 b'0', b'0', b'2', b'B', b'O', b'B', // Record 3
909 0x00, 0x08, 0x00, 0x00, // RDW header: length=8
910 b'0', b'0', b'3', b'C', b'A', b'R', b'O', b'L',
911 ];
912
913 let cursor = Cursor::new(test_data);
914
915 let options = DecodeOptions {
916 format: RecordFormat::RDW,
917 codepage: Codepage::ASCII,
918 ..DecodeOptions::default()
919 };
920
921 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
922
923 // Read all records
924 let mut count = 0;
925 for result in iterator.by_ref() {
926 assert!(result.is_ok(), "Record {count} should decode successfully");
927 count += 1;
928 }
929
930 assert_eq!(count, 3);
931 assert_eq!(iterator.current_record_index(), 3);
932 assert!(iterator.is_eof());
933 }
934
935 #[test]
936 fn test_iter_records_convenience() {
937 let copybook_text = r"
938 01 RECORD.
939 05 ID PIC 9(3).
940 05 NAME PIC X(5).
941 ";
942
943 let schema = parse_copybook(copybook_text).unwrap();
944
945 let test_data = b"001ALICE002BOB ";
946 let cursor = Cursor::new(test_data);
947
948 let options = DecodeOptions {
949 format: RecordFormat::Fixed,
950 ..DecodeOptions::default()
951 };
952
953 let iterator = iter_records(cursor, &schema, &options).unwrap();
954
955 assert_eq!(iterator.current_record_index(), 0);
956 assert!(!iterator.is_eof());
957 }
958
959 #[test]
960 fn test_iterator_with_empty_data() {
961 let copybook_text = r"
962 01 RECORD.
963 05 ID PIC 9(3).
964 05 NAME PIC X(5).
965 ";
966
967 let mut schema = parse_copybook(copybook_text).unwrap();
968 schema.lrecl_fixed = Some(8);
969
970 let test_data = b"";
971 let cursor = Cursor::new(test_data);
972
973 let options = DecodeOptions {
974 format: RecordFormat::Fixed,
975 ..DecodeOptions::default()
976 };
977
978 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
979
980 // Should immediately return None for empty data
981 assert!(iterator.next().is_none());
982 assert!(iterator.is_eof());
983 assert_eq!(iterator.current_record_index(), 0);
984 }
985
986 #[test]
987 fn test_iterator_raw_record_eof() {
988 let copybook_text = r"
989 01 RECORD.
990 05 ID PIC 9(3).
991 05 NAME PIC X(5).
992 ";
993
994 let schema = parse_copybook(copybook_text).unwrap();
995
996 let test_data = b"001ALICE";
997 let cursor = Cursor::new(test_data);
998
999 let options = DecodeOptions {
1000 format: RecordFormat::Fixed,
1001 ..DecodeOptions::default()
1002 };
1003
1004 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
1005
1006 // Read first record
1007 assert!(iterator.read_raw_record().unwrap().is_some());
1008 assert_eq!(iterator.current_record_index(), 1);
1009
1010 // Read second record (should be None)
1011 assert!(iterator.read_raw_record().unwrap().is_none());
1012 assert!(iterator.is_eof());
1013 }
1014
1015 #[test]
1016 fn test_iterator_collect_results() {
1017 let copybook_text = r"
1018 01 RECORD.
1019 05 ID PIC 9(3).
1020 05 NAME PIC X(5).
1021 ";
1022
1023 let mut schema = parse_copybook(copybook_text).unwrap();
1024 schema.lrecl_fixed = Some(8);
1025
1026 let test_data = b"001ALICE002BOB 003CAROL";
1027 let cursor = Cursor::new(test_data);
1028
1029 let options = DecodeOptions {
1030 format: RecordFormat::Fixed,
1031 codepage: Codepage::ASCII,
1032 ..DecodeOptions::default()
1033 };
1034
1035 let iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
1036
1037 // Collect all results
1038 let results: Vec<Result<Value>> = iterator.collect();
1039
1040 assert_eq!(results.len(), 3);
1041 for result in results {
1042 assert!(result.is_ok());
1043 }
1044 }
1045
1046 #[test]
1047 fn test_iterator_with_decode_error() {
1048 let copybook_text = r"
1049 01 RECORD.
1050 05 ID PIC 9(3).
1051 05 NAME PIC X(5).
1052 ";
1053
1054 let mut schema = parse_copybook(copybook_text).unwrap();
1055 schema.lrecl_fixed = Some(8);
1056
1057 // Create data that will decode successfully for first record
1058 let test_data = b"001ALICE";
1059 let cursor = Cursor::new(test_data);
1060
1061 let options = DecodeOptions {
1062 format: RecordFormat::Fixed,
1063 codepage: Codepage::ASCII,
1064 ..DecodeOptions::default()
1065 };
1066
1067 let mut iterator = RecordIterator::new(cursor, &schema, &options).unwrap();
1068
1069 // First record should decode successfully
1070 let first = iterator.next();
1071 assert!(first.is_some());
1072 assert!(first.unwrap().is_ok());
1073
1074 // Second call should return None (EOF)
1075 assert!(iterator.next().is_none());
1076 }
1077}