use std::ops::Range; use std::time::SystemTime; /// A segment of text read from a file, with optional line-range metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextFileSegment { pub file_name: String, pub content: String, pub line_range: Option>, pub last_modified: Option, /// Total number of lines in the source file (not just this segment). pub line_count: usize, } /// Result of attempting to read a file as text. pub enum TextFileReadResult { /// Successfully read as text. Segments { segments: Vec, bytes_read: usize, }, /// Not valid UTF-8 — caller should try the binary path. NotText, } /// Accumulates lines into [`TextFileSegment`]s for a set of (possibly empty) /// line ranges, enforcing a byte budget and tracking the total line count. /// /// **Line ending normalization**: `\n` and `\r\n` line endings are normalized /// to `\n` (LF) in the emitted [`TextFileSegment::content`]. Classic Mac /// `\r`-only line endings are **not** recognized as line separators (matching /// the behavior of `read_line()`, which only splits on `\n`). Lines are /// expected to be pushed with their terminators already stripped (as produced /// by `read_line()` + manual stripping). The trailing newline of the file, if /// present, is preserved via the `has_trailing_newline` flag passed to /// [`Self::push_line`]. pub(crate) struct TextFileAccumulator { file_name: String, last_modified: Option, effective_ranges: Vec>, whole_file: bool, max_bytes: usize, segments: Vec, total_bytes_read: usize, range_idx: usize, buf: Vec, buf_bytes: usize, truncated: bool, last_line: usize, current_line: usize, /// Whether the most recently pushed line had a trailing newline in the /// original file. Updated on every [`Self::push_line`] call so that after /// all lines are pushed, this reflects the final line's terminator state. last_line_had_newline: bool, } impl TextFileAccumulator { #[allow(clippy::single_range_in_vec_init)] pub(crate) fn new( file_name: String, last_modified: Option, requested_ranges: &[Range], max_bytes: usize, ) -> Self { let whole_file = requested_ranges.is_empty(); let effective_ranges = if whole_file { vec![1..usize::MAX] } else { let mut sorted = requested_ranges.to_vec(); sorted.sort_by_key(|r| r.start); sorted }; Self { file_name, last_modified, effective_ranges, whole_file, max_bytes, segments: Vec::new(), total_bytes_read: 0, range_idx: 0, buf: Vec::new(), buf_bytes: 0, truncated: false, last_line: 0, current_line: 0, last_line_had_newline: false, } } /// Pushes a single line (with its terminator already stripped) into the /// accumulator. /// /// `has_trailing_newline` indicates whether this line was terminated by a /// newline in the original file. For every line except possibly the last /// one in a file, this will be `true`. pub(crate) fn push_line(&mut self, line: String, has_trailing_newline: bool) { self.current_line += 1; self.last_line_had_newline = has_trailing_newline; if self.range_idx >= self.effective_ranges.len() { // Past all requested ranges — just count remaining lines. return; } // Past the current range — finalize it and advance. if self.current_line >= self.effective_ranges[self.range_idx].end { self.flush_range(false); self.range_idx += 1; } // Within the current range — accumulate. if self.range_idx < self.effective_ranges.len() { let range = &self.effective_ranges[self.range_idx]; if self.current_line >= range.start && self.current_line < range.end && !self.truncated { let line_bytes = line.len() + if self.buf.is_empty() { 0 } else { 1 }; if self.total_bytes_read + self.buf_bytes + line_bytes > self.max_bytes { self.truncated = true; } else { self.buf_bytes += line_bytes; self.last_line = self.current_line; self.buf.push(line); } } } } /// Emits a [`TextFileSegment`] for the current range and resets per-range /// state. Always produces a segment — even when the buffer is empty (e.g. /// the requested range was entirely past EOF). fn flush_range(&mut self, final_flush: bool) { let range = self.effective_ranges[self.range_idx].clone(); let line_range = if self.whole_file && !self.truncated { None } else if self.truncated { // `last_line` is the last line that was successfully buffered, or 0 // when the first in-range line already exceeded the byte budget (so // nothing was buffered). Clamp to `range.start` so a truncated // segment with no buffered lines yields an empty `start..start` // range instead of a reversed `start..0`. Some(range.start..self.last_line.max(range.start)) } else { Some(range) }; self.total_bytes_read += self.buf_bytes; let mut content = std::mem::take(&mut self.buf).join("\n"); // If this is the final flush of a non-truncated whole-file read // and the last line in the file had a trailing newline, preserve // it. This ensures round-tripping file content through the // accumulator doesn't silently drop a trailing newline (which // would otherwise cause data loss when the content is written // back to disk, e.g. during remote diff application). // // We skip this for truncated reads (the content is incomplete, so // appending a newline would be misleading) and for ranged reads // (which extract a slice, not the full file). if final_flush && self.whole_file && !self.truncated && self.last_line_had_newline { content.push('\n'); self.total_bytes_read += 1; } self.segments.push(TextFileSegment { file_name: self.file_name.clone(), content, line_range, last_modified: self.last_modified, line_count: 0, // Set in finalize() }); self.buf.clear(); self.buf_bytes = 0; self.truncated = false; self.last_line = 0; } pub(crate) fn finalize(mut self) -> (Vec, usize) { if self.range_idx < self.effective_ranges.len() { self.flush_range(true); } let total_line_count = self.current_line; for segment in &mut self.segments { segment.line_count = total_line_count; } (self.segments, self.total_bytes_read) } } #[cfg(test)] #[path = "text_file_reader_tests.rs"] mod tests;