Skip to main content

tale_ndjson/readers/
backseeking.rs

1//! Handle tailing or catting non-multiplex cases while pretty-printing
2//! everything we can. We can tuck these simple cases off in its own file and
3//! leave them to be simple.
4
5use std::fs::File;
6use std::io::{self, BufRead, BufReader, Read, Seek, Write};
7use std::path::PathBuf;
8use std::time::{Duration, Instant};
9
10use bytes::BytesMut;
11use miette::IntoDiagnostic;
12
13use super::FileProcessor;
14use crate::defaults::io::*;
15use crate::defaults::processing::*;
16use crate::errors::TaleError;
17use crate::{config, process_line, strip_line_ending};
18
19pub struct BackSeekingProcessor<'a> {
20    fpath: PathBuf,
21    initial_file_size: u64,
22    file: Option<File>,
23    outlock: io::StdoutLock<'a>,
24    buffer: BytesMut,
25    count: u16,
26}
27
28// TODO: finish implementing the trait; clean up.
29impl<'a> FileProcessor for BackSeekingProcessor<'a> {
30    fn process_lines<F>(&mut self, _line_processor: F) -> Result<(), TaleError>
31    where
32        F: FnMut(&str) -> Result<(), TaleError>,
33    {
34        let _temp_buffer = BytesMut::with_capacity(OUTPUT_BUFFER_CAPACITY);
35        let _temp_outlock = io::stdout().lock();
36
37        // process_line()
38
39        // Use existing tail() logic but intercept lines before output
40        // This would require refactoring tail() to be more modular
41        todo!("Refactor existing tail() method to support callback-based processing")
42    }
43
44    fn skip_lines(&mut self, _count: u64) -> Result<(), TaleError> {
45        // BackSeekingProcessor already handles this via move_to_position
46        // Could extract the line-skipping logic from there
47        todo!("Implement using existing offset logic")
48    }
49
50    fn file_size(&self) -> u64 {
51        self.initial_file_size
52    }
53
54    fn seek(&mut self, pos: io::SeekFrom) -> Result<u64, TaleError> {
55        if let Some(mut f) = self.file.as_ref() {
56            Ok(f.seek(pos).map_err(TaleError::from)?)
57        } else {
58            let mut file = File::open(&self.fpath).map_err(TaleError::from)?;
59            let actual = file.seek(pos).map_err(TaleError::from)?;
60            self.file = Some(file);
61            Ok(actual)
62        }
63    }
64
65    fn position(&self) -> u64 {
66        if let Some(mut f) = self.file.as_ref() {
67            f.stream_position().unwrap_or_default()
68        } else {
69            0
70        }
71    }
72}
73
74impl<'a> BackSeekingProcessor<'a> {
75    pub fn new(fpath: PathBuf) -> Self {
76        // briefly open the file and figure out its size
77        let file_size = if let Ok(mut file) = File::open(&fpath) {
78            file.seek(io::SeekFrom::End(0)).unwrap_or_default()
79        } else {
80            0
81        };
82
83        Self {
84            fpath,
85            initial_file_size: file_size,
86            file: None,
87            outlock: io::stdout().lock(),
88            buffer: BytesMut::with_capacity(OUTPUT_BUFFER_CAPACITY),
89            count: 0,
90        }
91    }
92
93    /// Find the right file offset to start reading & printing this file from,
94    /// given the arg input. This seeks forward or backwards by lines, and
95    /// returns the current file position. As a side effect, the file is
96    /// left at the correct position to begin reading. IMPORTANT: The caller
97    /// has to do any last by-lines forward seeking by themselves. This is a
98    /// weakness in the internal API.
99    pub fn move_to_position(
100        &mut self,
101        offset: i64,
102        units: config::OffsetUnit,
103        tailing: bool,
104    ) -> Result<File, TaleError> {
105        let mut file = File::open(&self.fpath)?;
106        // Short circuit if there is no work to do.
107        let file_size = file.seek(io::SeekFrom::End(0))?;
108        if file_size == 0 {
109            return Ok(file);
110        }
111
112        // Reset to start after size read.
113        file.seek(io::SeekFrom::Start(0))?;
114
115        // Set our position in the file based on offset unit.
116        match units {
117            config::OffsetUnit::Lines => {
118                if offset > 0 {
119                    // Positive offset: skip N lines from the beginning,
120                    // which we do NOT do here
121                    file.seek(io::SeekFrom::Start(0))?;
122                } else if offset < 0 {
123                    // Negative offset: start N lines from the end
124                    let start = self.move_n_lines_back(&mut file, (-offset) as u64)?;
125                    file.seek(io::SeekFrom::Start(start))?;
126                } else if tailing {
127                    // Zero offset: start from the end (no lines to show unless tailing)
128                    file.seek(io::SeekFrom::End(0))?;
129                }
130            }
131            config::OffsetUnit::Bytes => {
132                // Byte-based offset
133                if offset > 0 {
134                    // Positive offset: skip N bytes from the beginning
135                    file.seek(io::SeekFrom::Start(offset as u64))?;
136                } else if offset < 0 {
137                    // Negative offset: start N bytes from the end
138                    file.seek(io::SeekFrom::End(offset))?;
139                } else if tailing {
140                    // Zero offset: start from the end
141                    file.seek(io::SeekFrom::End(0))?;
142                }
143            }
144            config::OffsetUnit::Blocks => {
145                // This case is the as above, but we multiply offset by block size.
146                if offset > 0 {
147                    let byte_offset = (offset as u64) * BLOCK_SIZE;
148                    file.seek(io::SeekFrom::Start(byte_offset))?;
149                } else if offset < 0 {
150                    let byte_offset = offset * (BLOCK_SIZE as i64);
151                    file.seek(io::SeekFrom::End(byte_offset))?;
152                } else if tailing {
153                    file.seek(io::SeekFrom::End(0))?;
154                }
155            }
156        }
157
158        Ok(file)
159    }
160
161    /// Find the byte offset from the beginning of the file for the start of the
162    /// line to begin our pretty-printing. This is the seek backwards version.
163    /// It is made entirely of edge cases. Used only by
164    /// FileProcessor::move_to_position().
165    fn move_n_lines_back(&mut self, file: &mut File, line_count: u64) -> Result<u64, TaleError> {
166        let file_size = file.seek(io::SeekFrom::End(0))?;
167        if file_size == 0 {
168            return Ok(0);
169        }
170
171        const BUFFER_SIZE: usize = 8192;
172        let mut buffer = vec![0u8; BUFFER_SIZE];
173        let mut lines_found = 0u64;
174
175        // First check if the file ends with a newline
176        file.seek(io::SeekFrom::End(-1))?;
177        let mut last_byte = [0u8; 1];
178        file.read_exact(&mut last_byte)?;
179        let ends_with_newline = last_byte[0] == b'\n';
180
181        // To get the last N lines, we need to find the right number of newlines
182        // For a file that doesn't end with newline: last line is after the last newline
183        // For a file that ends with newline: last line is between the last two newlines
184        let target_newlines = if ends_with_newline { line_count } else { line_count - 1 };
185
186        let mut pos = file_size;
187
188        loop {
189            // how much should we read?
190            let chunk_size = std::cmp::min(BUFFER_SIZE as u64, pos) as usize;
191            if chunk_size == 0 {
192                // We've reached the beginning of the file
193                return Ok(0);
194            }
195
196            // Read a chonk. Chunk. Whatever.
197            pos -= chunk_size as u64;
198            file.seek(io::SeekFrom::Start(pos))?;
199            file.read_exact(&mut buffer[..chunk_size])?;
200
201            // Count newlines in reverse order
202            for (i, &byte) in buffer[..chunk_size].iter().enumerate().rev() {
203                if byte == b'\n' {
204                    lines_found += 1;
205                    if lines_found > target_newlines {
206                        // Found enough lines, return position after this newline
207                        return Ok(pos + i as u64 + 1);
208                    }
209                }
210            }
211
212            // We hit the beginning: not enough lines. We start at the very
213            // beginning, a very good place to start.
214            if pos == 0 {
215                return Ok(0);
216            }
217        }
218    }
219
220    /// Process a single line through the formatting pipeline
221    pub fn process_line(&mut self, line: &str) -> Result<(), TaleError> {
222        process_line(line, &mut self.buffer, &mut self.outlock)
223            .map_err(|e| TaleError::from(std::io::Error::other(e.to_string())))?;
224        self.count += 1;
225        self.flush_if_needed()
226    }
227
228    /// Flush output if we've processed enough lines
229    pub fn flush_if_needed(&mut self) -> Result<(), TaleError> {
230        if self.count >= FLUSH_LINE_COUNT {
231            self.outlock.flush()?;
232            self.count = 0;
233        }
234        Ok(())
235    }
236
237    /// Force flush output
238    pub fn flush(&mut self) -> Result<(), TaleError> {
239        self.outlock.flush()?;
240        self.count = 0;
241        Ok(())
242    }
243
244    pub fn tail(&mut self) -> miette::Result<()> {
245        let tailing = config::tailing();
246        let offset_unit = config::offset_unit();
247        let offset = config::offset();
248
249        let file = self
250            .move_to_position(offset, offset_unit, tailing)
251            .map_err(miette::Report::from)?;
252        let mut reader = BufReader::new(file);
253
254        // If we've got a positive line offset, we still need to skip our N lines
255        if offset > 0 && matches!(offset_unit, config::OffsetUnit::Lines) {
256            let consume_me = (&mut reader).lines().take(offset as usize);
257            // We then must consume them. this feels v inefficient but I do not know.
258            let _count = consume_me.count();
259        };
260
261        // Now at last we get to start printing. What a fuss.
262        let mut line = String::with_capacity(LINE_CAPACITY);
263        while reader.read_line(&mut line).into_diagnostic()? != 0 {
264            strip_line_ending(&mut line);
265            self.process_line(line.as_str()).map_err(miette::Report::from)?;
266            line.clear();
267        }
268        self.flush().map_err(miette::Report::from)?;
269
270        if !tailing {
271            return Ok(());
272        }
273
274        // Now we tell a tale of tailing.
275        let mut last_flush = Instant::now();
276
277        // Get the file back from the reader
278        let mut file = reader.into_inner();
279        let mut file_position = file.stream_position().into_diagnostic()?;
280
281        // polling loop. TODO consider better impl
282        loop {
283            std::thread::sleep(Duration::from_millis(100));
284
285            // Check if file has grown
286            let current_size = file.seek(io::SeekFrom::End(0)).into_diagnostic()?;
287            if current_size > file_position {
288                // Hide and seek, trains and sewing machines.
289                file.seek(io::SeekFrom::Start(file_position)).into_diagnostic()?;
290                let mut tail_reader = BufReader::new(&file);
291
292                match tail_reader.read_line(&mut line).into_diagnostic()? {
293                    0 => {
294                        // EOF - no new data available, continue polling
295                        continue;
296                    }
297                    _ => {
298                        strip_line_ending(&mut line);
299                        // New data available - process it.
300                        process_line(&line, &mut self.buffer, &mut self.outlock)?;
301                        if last_flush.elapsed() >= TAIL_FLUSH_INTERVAL {
302                            self.outlock.flush().into_diagnostic()?;
303                            last_flush = Instant::now();
304                        }
305
306                        line.clear();
307                        self.buffer.clear();
308                    }
309                }
310
311                // Note where we finished reading so we can figure out if we get more.
312                file_position = file.stream_position().into_diagnostic()?;
313            }
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn seeking_backwards() {
324        use std::io::{Read, Seek, Write};
325
326        use tempfile::NamedTempFile;
327
328        // Create a temporary file with known content
329        let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
330        let content = "line1\nline2\nline3\nline4\nline5\n";
331        temp_file
332            .write_all(content.as_bytes())
333            .expect("Failed to write to temp file");
334
335        let pathbuf = PathBuf::from(temp_file.path());
336        let mut processor = BackSeekingProcessor::new(pathbuf);
337        let mut file = File::open(temp_file.path()).expect("Failed to open temp file");
338
339        // Test getting last 2 lines (should start after "line3\n")
340        let pos = processor
341            .move_n_lines_back(&mut file, 2)
342            .expect("Failed to find position");
343        file.seek(io::SeekFrom::Start(pos)).expect("Failed to seek");
344
345        let mut remaining = String::new();
346        file.read_to_string(&mut remaining).expect("Failed to read remaining");
347        assert_eq!(remaining, "line4\nline5\n");
348
349        // Test getting last line (should start after "line4\n")
350        let pos = processor
351            .move_n_lines_back(&mut file, 1)
352            .expect("Failed to find position");
353        file.seek(io::SeekFrom::Start(pos)).expect("Failed to seek");
354
355        let mut remaining = String::new();
356        file.read_to_string(&mut remaining).expect("Failed to read remaining");
357        assert_eq!(remaining, "line5\n");
358
359        // Test getting more lines than exist (should start from beginning)
360        let pos = processor
361            .move_n_lines_back(&mut file, 10)
362            .expect("Failed to find position");
363        assert_eq!(pos, 0);
364    }
365
366    #[test]
367    fn seeking_in_empty() {
368        use tempfile::NamedTempFile;
369        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
370        let pathbuf = PathBuf::from(temp_file.path());
371        let mut processor = BackSeekingProcessor::new(pathbuf);
372        let mut file = File::open(temp_file.path()).expect("Failed to open temp file");
373
374        let pos = processor
375            .move_n_lines_back(&mut file, 5)
376            .expect("Failed to find position");
377        assert_eq!(pos, 0);
378    }
379
380    #[test]
381    fn good_circular_buffer_byte_logic() {
382        // Test the circular buffer logic without stdin dependency
383        let input_data = b"0123456789abcdefghij";
384        let buffer_size = 10;
385        let mut circular_buffer = vec![0u8; buffer_size];
386        let mut pos = 0usize;
387
388        // Simulate writing to circular buffer
389        for &byte in input_data {
390            circular_buffer[pos % buffer_size] = byte;
391            pos += 1;
392        }
393
394        // Should have wrapped around, last 10 bytes should be "abcdefghij"
395        let _total_read = input_data.len() as u64;
396        let _bytes_to_show = buffer_size as u64;
397        let start_pos = pos % buffer_size;
398
399        // Extract the last bytes_to_show bytes
400        let mut result = Vec::with_capacity(buffer_size);
401        for i in 0..buffer_size {
402            result.push(circular_buffer[(start_pos + i) % buffer_size]);
403        }
404
405        assert_eq!(result, b"abcdefghij");
406    }
407
408    #[test]
409    fn circular_buffer_partial_fill_works() {
410        // Test circular buffer when input is smaller than buffer
411        let input_data = b"hello";
412        let buffer_size = 10;
413        let mut circular_buffer = vec![0u8; buffer_size];
414
415        // Fill buffer
416        for (i, &byte) in input_data.iter().enumerate() {
417            circular_buffer[i] = byte;
418        }
419
420        let bytes_to_output = input_data.len();
421        let result = &circular_buffer[..bytes_to_output];
422
423        assert_eq!(result, b"hello");
424    }
425
426    #[test]
427    fn circular_line_logic() {
428        use std::collections::VecDeque;
429
430        // Test VecDeque circular behavior for line buffering
431        let lines_to_keep = 3;
432        let mut line_buffer: VecDeque<String> = VecDeque::with_capacity(lines_to_keep);
433
434        let input_lines = vec!["line1", "line2", "line3", "line4", "line5"];
435
436        for line in input_lines {
437            if line_buffer.len() >= lines_to_keep {
438                line_buffer.pop_front();
439            }
440            line_buffer.push_back(line.to_string());
441        }
442
443        // Should contain last 3 lines
444        let result: Vec<String> = line_buffer.into_iter().collect();
445        assert_eq!(result, vec!["line3", "line4", "line5"]);
446    }
447
448    #[test]
449    fn handles_overshoots_correctly() {
450        let overshoot = b"partial line\ncomplete line\nanother";
451        let overshoot_str = String::from_utf8_lossy(overshoot);
452        let mut remaining = overshoot_str.as_ref();
453        let mut complete_lines = Vec::new();
454
455        while let Some(pos) = remaining.find('\n') {
456            complete_lines.push(&remaining[..pos]);
457            remaining = &remaining[pos + 1..];
458        }
459
460        assert_eq!(complete_lines, vec!["partial line", "complete line"]);
461        assert_eq!(remaining, "another");
462    }
463}