Skip to main content

tale_ndjson/readers/
mod.rs

1//! File processing architecture with different strategies for various use
2//! cases.
3//!
4//! - **BufferedFileProcessor**: Simple forward-only reading for small files
5//! - **ChunkedFileReader**: Memory-efficient processing with Strategy-based
6//!   adaptation
7//! - **BackSeekingProcessor**: Handles backward seeking and tail-like
8//!   functionality
9//!
10//! ChunkedFileReader uses the Strategy pattern for chunk size management:
11//! - **StaticStrategy**: Fixed optimal chunk size (fastest, predictable memory)
12//! - **AdaptiveStrategy**: Dynamic sizing based on performance metrics
13//! - **ConservativeStrategy**: Memory-constrained environments
14//!
15//! `create_file_processor()` automatically selects the best processor based on:
16//! - File size and offset requirements
17//! - Memory constraints (--chunked, --no-chunked flags)
18//! - Operation type (negative offsets → BackSeekingProcessor)
19
20mod backseeking;
21mod buffered;
22mod chunked; // the chunked file processor
23mod stdin;
24pub mod strategies; // chunking strategies
25
26use std::io::{self, SeekFrom, Write};
27use std::path::{Path, PathBuf};
28
29pub use backseeking::*;
30pub use buffered::*;
31use bytes::BytesMut;
32pub use chunked::*;
33use miette::{IntoDiagnostic, Result};
34use owo_colors::OwoColorize;
35pub use stdin::*;
36pub use strategies::*;
37
38use crate::defaults::io::*;
39use crate::defaults::processing::*;
40use crate::errors::{FileError, TaleError, find_similar_files};
41use crate::multiplexed::watcher::{MultiFileWatcher, WatchEvent, WatcherConfig};
42use crate::{config, process_line};
43
44/// Wait for a file to be created when using sticky mode
45async fn wait_for_file_creation(target_path: &Path) -> Result<()> {
46    use std::time::Duration;
47
48    eprintln!("Watching for '{}'…", target_path.display().yellow().bold());
49
50    // Get the parent directory to watch
51    let parent_dir = target_path.parent().ok_or_else(|| {
52        TaleError::from(Box::new(FileError::NotFound {
53            path: target_path.to_path_buf(),
54            similar_files: vec!["Parent directory not found".to_string()],
55        }))
56    })?;
57
58    // Check if parent directory exists
59    if !parent_dir.exists() {
60        return Err(TaleError::from(Box::new(FileError::NotFound {
61            path: parent_dir.to_path_buf(),
62            similar_files: vec!["Parent directory must exist for file watching".to_string()],
63        }))
64        .into());
65    }
66
67    let target_filename = target_path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
68        miette::Report::from(TaleError::from(Box::new(FileError::NotFound {
69            path: target_path.to_path_buf(),
70            similar_files: vec!["Invalid filename".to_string()],
71        })))
72    })?;
73
74    // Create a file watcher for the parent directory
75    let mut watcher = MultiFileWatcher::new(WatcherConfig::default());
76
77    // Add the parent directory to watch
78    watcher.add_files(vec![parent_dir]).await?;
79
80    // Start watching
81    let mut event_receiver = watcher.watch().await?;
82
83    let mut elapsed_seconds = 0;
84    let mut last_message_time = std::time::Instant::now();
85
86    loop {
87        // Use a timeout to periodically show waiting messages
88        match tokio::time::timeout(Duration::from_secs(5), event_receiver.recv()).await {
89            Ok(Some(event)) => {
90                match event {
91                    WatchEvent::FileCreated(created_path) => {
92                        if let Some(created_filename) = created_path.file_name().and_then(|n| n.to_str())
93                            && created_filename == target_filename
94                        {
95                            eprintln!("+++> '{}' created; tailing", target_filename.yellow().bold());
96                            return Ok(());
97                        }
98                    }
99                    WatchEvent::Error(_err) => {
100                        // Continue watching despite errors
101                    }
102                    _ => {
103                        // Ignore other events (modify, delete, etc.)
104                    }
105                }
106            }
107            Ok(None) => {
108                // Channel closed
109                return Err(TaleError::from(Box::new(FileError::NotFound {
110                    path: target_path.to_path_buf(),
111                    similar_files: vec!["File watcher stopped unexpectedly".to_string()],
112                }))
113                .into());
114            }
115            Err(_) => {
116                // Timeout - show periodic message
117                elapsed_seconds += 5;
118                if last_message_time.elapsed() >= Duration::from_secs(30) {
119                    eprintln!(
120                        "Still watching for '{}' ({}s elapsed)...",
121                        target_path.display().yellow().bold(),
122                        elapsed_seconds.bright_magenta()
123                    );
124                    last_message_time = std::time::Instant::now();
125                }
126
127                // Check if file appeared while we weren't watching (race condition)
128                if target_path.exists() {
129                    eprintln!("+++> '{}' created; tailing", target_filename.yellow().bold());
130                    return Ok(());
131                }
132            }
133        }
134    }
135}
136
137/// We're displaying a file. Let's chug through it.
138pub async fn handle_file(fpath: &Path) -> Result<()> {
139    let sticky = config::sticky();
140
141    // Check if file exists and provide helpful suggestions
142    if !fpath.exists() {
143        if sticky {
144            // In sticky mode, wait for the file to be created
145            wait_for_file_creation(fpath).await?;
146
147            // After the file is created, verify it's actually a file (not a directory)
148            if !fpath.is_file() {
149                return Err(TaleError::from(Box::new(FileError::not_a_file_with_type(fpath.to_path_buf()))).into());
150            }
151        } else {
152            // In normal mode, return error with suggestions
153            let similar_files = find_similar_files(fpath);
154            return Err(TaleError::from(Box::new(FileError::not_found_with_suggestions(
155                fpath.to_path_buf(),
156                similar_files,
157            )))
158            .into());
159        }
160    }
161
162    // Check if it's actually a file (not a directory, etc.)
163    if !fpath.is_file() {
164        return Err(TaleError::from(Box::new(FileError::not_a_file_with_type(fpath.to_path_buf()))).into());
165    }
166
167    // Try to create processor and provide helpful context for common errors
168    let mut processor = create_file_processor(fpath, None).map_err(|e| enhance_error_context(e, fpath))?;
169
170    // BackSeekingProcessor handles its own special cases (negative offsets, bytes,
171    // blocks)
172    if let FileProcessorType::BackSeeking(mut backseeker) = processor {
173        return backseeker.tail();
174    }
175
176    // For buffered and chunked processors, handle offset scenarios
177    let offset = config::offset();
178    let offset_unit = config::offset_unit();
179
180    // Handle different offset scenarios
181    match (offset.is_positive(), offset_unit) {
182        // Positive line offset: skip lines from start
183        (true, config::OffsetUnit::Lines) if offset > 0 => {
184            processor.skip_lines(offset as u64)?;
185
186            // Process remaining lines
187            let mut buffer = BytesMut::with_capacity(OUTPUT_BUFFER_CAPACITY);
188            let mut outlock = io::stdout().lock();
189
190            processor.process_lines(|line| {
191                process_line(line, &mut buffer, &mut outlock)
192                    .map_err(|e| TaleError::from(std::io::Error::other(e.to_string())))
193            })?;
194
195            outlock.flush().into_diagnostic()?;
196        }
197
198        // Zero offset or other cases: process entire file
199        _ => {
200            let mut buffer = BytesMut::with_capacity(OUTPUT_BUFFER_CAPACITY);
201            let mut outlock = io::stdout().lock();
202
203            processor.process_lines(|line| {
204                process_line(line, &mut buffer, &mut outlock)
205                    .map_err(|e| TaleError::from(std::io::Error::other(e.to_string())))
206            })?;
207
208            outlock.flush().into_diagnostic()?;
209        }
210    }
211    Ok(())
212}
213
214fn enhance_error_context(error: TaleError, path: &Path) -> TaleError {
215    match error {
216        TaleError::Io(io_error) => {
217            let crate::errors::IoError::OperationFailed { source, .. } = io_error.as_ref();
218            if source.kind() == std::io::ErrorKind::PermissionDenied {
219                let suggestion = if cfg!(unix) {
220                    Some(format!("Try: chmod +r {}", path.display()))
221                } else {
222                    Some("Check file permissions in Properties".to_string())
223                };
224                Box::new(FileError::permission_denied_with_suggestion(
225                    path.to_path_buf(),
226                    suggestion,
227                ))
228                .into()
229            } else {
230                TaleError::Io(io_error)
231            }
232        }
233        other => other,
234    }
235}
236
237/// Create the optimal file processor for the given file and operation
238pub fn create_file_processor<P: AsRef<Path>>(
239    path: P,
240    file_size_hint: Option<u64>,
241) -> Result<FileProcessorType<'static>, TaleError> {
242    let path = path.as_ref();
243    let _strategy = if cfg!(debug_assertions) && config::conservative() {
244        Strategy::Static(StaticStrategy::conservative())
245    } else {
246        // Normal smart adaptation
247        Strategy::default()
248    };
249
250    // Get file size if not provided
251    let file_size = file_size_hint.unwrap_or_else(|| std::fs::metadata(path).map(|m| m.len()).unwrap_or(0));
252
253    let offset = config::offset();
254    let offset_unit = config::offset_unit();
255    let large_offset = offset.abs() > LARGE_OFFSET_THRESHOLD as i64;
256
257    // Pick which processor suits the situation based on file size and offset
258    let use_chunked = !config::disable_chunked()
259        && (config::force_chunked()
260            || (file_size > CHUNKED_WITH_OFFSET_FILE_SIZE && large_offset)
261            || file_size > ALWAYS_CHUNKED_FILE_SIZE);
262
263    // This is the only reader that can handle negative block and byte offsets, and
264    // it already handles them reasonably (though its chunks might not be
265    // optimal). This is something I need to refactor away.
266    if offset < 0 || matches!(offset_unit, config::OffsetUnit::Bytes | config::OffsetUnit::Blocks) {
267        let processor = BackSeekingProcessor::new(PathBuf::from(path));
268        return Ok(FileProcessorType::BackSeeking(processor));
269    }
270
271    if use_chunked {
272        let reader = ChunkedFileReader::with_optimal_config(path)?;
273        return Ok(FileProcessorType::Chunked(Box::new(reader)));
274    }
275
276    // We'll get here if we have a positive by-lines offset.
277    let reader = BufferedFileProcessor::new(path)?;
278    Ok(FileProcessorType::Buffered(reader))
279}
280
281/// Trait for abstracting different file reading strategies
282pub trait FileProcessor {
283    /// Process the entire file, calling the provided closure for each line
284    fn process_lines<F>(&mut self, line_processor: F) -> Result<(), TaleError>
285    where
286        F: FnMut(&str) -> Result<(), TaleError>;
287
288    /// Skip a specified number of lines from the current position
289    fn skip_lines(&mut self, count: u64) -> Result<(), TaleError>;
290
291    /// Get the file size in bytes
292    fn file_size(&self) -> u64;
293
294    /// Seek to a specific position in the file
295    fn seek(&mut self, pos: SeekFrom) -> Result<u64, TaleError>;
296
297    /// Get current position in the file
298    fn position(&self) -> u64;
299}
300
301/// Chonked vs buffered vs can-go-backwards variants.
302pub enum FileProcessorType<'a> {
303    Buffered(BufferedFileProcessor),
304    Chunked(Box<ChunkedFileReader>),
305    BackSeeking(BackSeekingProcessor<'a>),
306}
307
308impl<'a> FileProcessor for FileProcessorType<'a> {
309    fn process_lines<F>(&mut self, line_processor: F) -> Result<(), TaleError>
310    where
311        F: FnMut(&str) -> Result<(), TaleError>,
312    {
313        match self {
314            FileProcessorType::Buffered(processor) => processor.process_lines(line_processor),
315            FileProcessorType::Chunked(processor) => processor.process_lines(line_processor),
316            FileProcessorType::BackSeeking(processor) => processor.process_lines(line_processor),
317        }
318    }
319
320    fn skip_lines(&mut self, count: u64) -> Result<(), TaleError> {
321        match self {
322            FileProcessorType::Buffered(processor) => processor.skip_lines(count),
323            FileProcessorType::Chunked(processor) => processor.skip_lines(count),
324            FileProcessorType::BackSeeking(processor) => processor.skip_lines(count),
325        }
326    }
327
328    fn file_size(&self) -> u64 {
329        match self {
330            FileProcessorType::Buffered(processor) => processor.file_size(),
331            FileProcessorType::Chunked(processor) => processor.file_size(),
332            FileProcessorType::BackSeeking(processor) => processor.file_size(),
333        }
334    }
335
336    fn seek(&mut self, pos: SeekFrom) -> Result<u64, TaleError> {
337        match self {
338            FileProcessorType::Buffered(processor) => processor.seek(pos),
339            FileProcessorType::Chunked(processor) => processor.seek(pos),
340            FileProcessorType::BackSeeking(processor) => processor.seek(pos),
341        }
342    }
343
344    fn position(&self) -> u64 {
345        match self {
346            FileProcessorType::Buffered(processor) => processor.position(),
347            FileProcessorType::Chunked(processor) => processor.position(),
348            FileProcessorType::BackSeeking(processor) => processor.position(),
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use std::io::Write;
356
357    use tempfile::NamedTempFile;
358
359    use super::*;
360    use crate::config::ConfigOpts;
361    use crate::tests::TestLogPattern;
362
363    fn create_test_file(content: &str) -> NamedTempFile {
364        let mut file = NamedTempFile::new().expect("Failed to create temp file");
365        file.write_all(content.as_bytes()).expect("Failed to write test data");
366        file.flush().expect("Failed to flush test file");
367        file
368    }
369
370    #[test]
371    fn chonk_size_optimizer() {
372        // Small files should use small chunks
373        assert_eq!(optimal_chunk_size(500_000, None), 8_192);
374
375        // Medium files should use medium chunks
376        assert_eq!(optimal_chunk_size(50_000_000, None), 131_072); // Updated for production defaults
377
378        // Large files should use large chunks
379        assert_eq!(optimal_chunk_size(500_000_000, None), 524_288); // Updated for production defaults
380
381        // Memory constraint should be respected (this test needs to be removed
382        // as it's no longer supported)
383        // assert_eq!(optimal_chunk_size(500_000_000, Some(100_000)), 10_000);
384    }
385
386    #[test]
387    fn processor_selection() {
388        let testfp = crate::tests::create_test_file(120_000, TestLogPattern::Canonical);
389
390        // Test 1: Negative offset always uses Simple processor
391        crate::config::with_config(
392            ConfigOpts {
393                offset: -20,
394                offset_unit: config::OffsetUnit::Lines,
395                force_chunked: false,
396                disable_chunked: false,
397                ..ConfigOpts::default()
398            },
399            || {
400                let result = create_file_processor(&testfp, Some(1_000_000_000))
401                    .expect("should create processor for negative offset");
402                assert!(
403                    matches!(result, FileProcessorType::BackSeeking(_)),
404                    "Negative offset should use Simple processor"
405                );
406            },
407        );
408
409        // Test 2: Byte offset units always use Simple processor
410        crate::config::with_config(
411            ConfigOpts {
412                offset: 100,
413                offset_unit: config::OffsetUnit::Bytes,
414                force_chunked: false,
415                disable_chunked: false,
416                ..ConfigOpts::default()
417            },
418            || {
419                let result = create_file_processor(&testfp, Some(1_000_000_000))
420                    .expect("should create processor for byte offset");
421                assert!(
422                    matches!(result, FileProcessorType::BackSeeking(_)),
423                    "Byte offset should use Simple processor"
424                );
425            },
426        );
427
428        // Test 3: Block offset units always use Simple processor
429        crate::config::with_config(
430            ConfigOpts {
431                offset: 100,
432                offset_unit: config::OffsetUnit::Blocks,
433                force_chunked: false,
434                disable_chunked: false,
435                ..ConfigOpts::default()
436            },
437            || {
438                let result = create_file_processor(&testfp, Some(1_000_000_000))
439                    .expect("should create processor for block offset");
440                assert!(
441                    matches!(result, FileProcessorType::BackSeeking(_)),
442                    "Block offset should use Simple processor"
443                );
444            },
445        );
446
447        // Test 4: force_chunked=true uses Chunked processor (when not disabled)
448        crate::config::with_config(
449            ConfigOpts {
450                offset: 100,
451                offset_unit: config::OffsetUnit::Lines,
452                force_chunked: true,
453                disable_chunked: false,
454                ..ConfigOpts::default()
455            },
456            || {
457                let result = create_file_processor(&testfp, Some(100_000_000))
458                    .expect("should create processor for force_chunked");
459                assert!(
460                    matches!(result, FileProcessorType::Chunked(_)),
461                    "force_chunked should use Chunked processor"
462                );
463            },
464        );
465
466        // Test 5: disable_chunked=true prevents Chunked processor
467        crate::config::with_config(
468            ConfigOpts {
469                offset: 20_000, // Large offset
470                offset_unit: config::OffsetUnit::Lines,
471                force_chunked: false,
472                disable_chunked: true,
473                ..ConfigOpts::default()
474            },
475            || {
476                let result = create_file_processor(&testfp, Some(200_000_000))
477                    .expect("should create processor for disable_chunked");
478                assert!(
479                    matches!(result, FileProcessorType::Buffered(_)),
480                    "disable_chunked should prevent Chunked processor"
481                );
482            },
483        );
484
485        // Test 6: Large file (>1GB) uses Chunked processor
486        crate::config::with_config(
487            ConfigOpts {
488                offset: 100,
489                offset_unit: config::OffsetUnit::Lines,
490                force_chunked: false,
491                disable_chunked: false,
492                ..ConfigOpts::default()
493            },
494            || {
495                let result = create_file_processor(&testfp, Some(1_500_000_000)) // 1.5GB
496                    .expect("should create processor for large file");
497                assert!(
498                    matches!(result, FileProcessorType::Chunked(_)),
499                    "Large file (>1GB) should use Chunked processor"
500                );
501            },
502        );
503
504        // Test 7: Large file + large offset uses Chunked processor
505        crate::config::with_config(
506            ConfigOpts {
507                offset: 20_000, // Large offset (>10,000)
508                offset_unit: config::OffsetUnit::Lines,
509                force_chunked: false,
510                disable_chunked: false,
511                ..ConfigOpts::default()
512            },
513            || {
514                let result = create_file_processor(&testfp, Some(150_000_000)) // 150MB + large offset
515                    .expect("should create processor for large file + large offset");
516                assert!(
517                    matches!(result, FileProcessorType::Chunked(_)),
518                    "Large file (>100MB) + large offset (>10k) should use Chunked processor"
519                );
520            },
521        );
522
523        // Test 8: Small file + small offset uses Buffered processor
524        crate::config::with_config(
525            ConfigOpts {
526                offset: 100, // Small offset
527                offset_unit: config::OffsetUnit::Lines,
528                force_chunked: false,
529                disable_chunked: false,
530                ..ConfigOpts::default()
531            },
532            || {
533                let result = create_file_processor(&testfp, Some(10_000_000)) // 10MB
534                    .expect("should create processor for small file + small offset");
535                assert!(
536                    matches!(result, FileProcessorType::Buffered(_)),
537                    "Small file + small offset should use Buffered processor"
538                );
539            },
540        );
541    }
542
543    #[test]
544    fn can_create_chonker() {
545        let data = b"line1\nline2\nline3\n".to_vec();
546        let chunk = FileChunk::new(data.clone(), 0, data.len() as u64);
547
548        assert_eq!(chunk.size(), data.len());
549        assert!(!chunk.is_empty());
550        assert!(chunk.starts_at_line_boundary);
551        assert!(chunk.ends_at_line_boundary);
552    }
553
554    #[test]
555    fn can_chunkread_small_files() -> Result<(), TaleError> {
556        let test_data = "line1\nline2\nline3\n";
557        let temp_file = create_test_file(test_data);
558
559        let config = ChunkConfig {
560            overlap_size: 2,
561            low_memory_mode: true,
562        };
563        let strategy = StaticStrategy {
564            chunk_size: 8, // Small chunks to test boundary handling
565            config: config.clone(),
566        };
567
568        let mut reader = ChunkedFileReader::with_strategy(temp_file.path(), Strategy::Static(strategy))?;
569
570        assert_eq!(reader.file_size(), test_data.len() as u64);
571        assert_eq!(reader.position(), 0);
572        assert!(!reader.is_at_end());
573
574        // Read chunks and verify content
575        let mut all_content = String::new();
576        while let Some(chunk) = reader.read_chunk()? {
577            let chunk_str = std::str::from_utf8(&chunk.data).expect("we expected a valid utf8 string in this test");
578            all_content.push_str(chunk_str);
579        }
580
581        assert_eq!(all_content, test_data);
582        assert!(reader.is_at_end());
583
584        Ok(())
585    }
586
587    #[test]
588    fn line_boundary_handling() {
589        let data = b"line1\nline2\npartial".to_vec();
590        let data_len = data.len();
591        let mut chunk = FileChunk::new(data, 0, data_len as u64);
592
593        assert!(!chunk.ends_at_line_boundary);
594
595        let remainder = chunk.split_at_last_line();
596        assert!(remainder.is_some());
597        assert_eq!(
598            remainder.expect("we expected some remainder after the end of the line"),
599            b"partial"
600        );
601        assert_eq!(chunk.data, b"line1\nline2\n");
602        assert!(chunk.ends_at_line_boundary);
603    }
604
605    #[test]
606    fn chunk_iterator_works() {
607        let data = b"line1\nline2\nline3".to_vec();
608        let data_len = data.len();
609        let chunk = FileChunk::new(data, 0, data_len as u64);
610
611        let lines: Vec<&str> = chunk.lines().collect();
612        assert_eq!(lines, vec!["line1", "line2", "line3"]);
613    }
614
615    #[test]
616    fn buffer_thing_works() -> Result<(), TaleError> {
617        let test_data = "line1\nline2\nline3\n";
618        let temp_file = create_test_file(test_data);
619
620        let mut processor = BufferedFileProcessor::new(temp_file.path())?;
621
622        assert_eq!(processor.file_size(), test_data.len() as u64);
623        assert_eq!(processor.position(), 0);
624
625        let mut lines = Vec::new();
626        processor.process_lines(|line| {
627            lines.push(line.to_string());
628            Ok(())
629        })?;
630
631        assert_eq!(lines, vec!["line1", "line2", "line3"]);
632        Ok(())
633    }
634
635    #[test]
636    fn abstract_processor_impl_factory_noun() -> Result<(), TaleError> {
637        let test_data = "line1\nline2\nline3\n";
638        let temp_file = create_test_file(test_data);
639
640        // Use with_config to isolate this test
641        config::with_config(ConfigOpts::default(), || {
642            // Small file should use buffered processor
643            let mut processor = create_file_processor(temp_file.path(), None).expect("should create processor");
644            assert_eq!(processor.file_size(), test_data.len() as u64);
645
646            let mut lines = Vec::new();
647            processor
648                .process_lines(|line| {
649                    lines.push(line.to_string());
650                    Ok(())
651                })
652                .expect("should process lines");
653
654            assert_eq!(lines, vec!["line1", "line2", "line3"]);
655        });
656
657        Ok(())
658    }
659
660    #[test]
661    fn can_skip_chunked_lines() -> Result<(), TaleError> {
662        // Create test data with more lines to test chunk boundaries
663        let test_data = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
664        let temp_file = create_test_file(test_data);
665
666        // Use small chunk size to test boundary handling
667        let config = ChunkConfig {
668            overlap_size: 2,
669            low_memory_mode: true,
670        };
671        let strategy = StaticStrategy {
672            chunk_size: 15, // Small enough to split across chunks
673            config: config.clone(),
674        };
675
676        let mut reader = ChunkedFileReader::with_strategy(temp_file.path(), Strategy::Static(strategy))?;
677
678        // Skip first 3 lines
679        reader.skip_lines(3)?;
680
681        // Collect remaining lines
682        let mut remaining_lines = Vec::new();
683        reader.process_lines(|line| {
684            remaining_lines.push(line.to_string());
685            Ok(())
686        })?;
687
688        // Should have lines 4-10
689        assert_eq!(
690            remaining_lines,
691            vec!["line4", "line5", "line6", "line7", "line8", "line9", "line10"]
692        );
693
694        Ok(())
695    }
696
697    #[test]
698    fn chunked_skip_lines_partial_chunk() -> Result<(), TaleError> {
699        // Test case where skip_lines needs to stop in the middle of a chunk
700        let test_data = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
701        let temp_file = create_test_file(test_data);
702
703        let config = ChunkConfig {
704            overlap_size: 1,
705            low_memory_mode: true,
706        };
707        let strategy = StaticStrategy {
708            chunk_size: 8, // Will create multiple small chunks
709            config: config.clone(),
710        };
711
712        let mut reader = ChunkedFileReader::with_strategy(temp_file.path(), Strategy::Static(strategy))?;
713
714        // Skip exactly 5 lines (should stop mid-chunk)
715        reader.skip_lines(5)?;
716
717        // Get next line
718        let mut next_lines = Vec::new();
719        reader.process_lines(|line| {
720            next_lines.push(line.to_string());
721            if next_lines.len() >= 2 {
722                return Ok(()); // Just get first 2 lines after skip
723            }
724            Ok(())
725        })?;
726
727        // Should get lines "f" and "g"
728        assert!(next_lines.len() >= 2);
729        assert_eq!(next_lines[0], "f");
730        assert_eq!(next_lines[1], "g");
731
732        Ok(())
733    }
734}