agentmux_srv\backend/
readutil.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! File reading utilities for forward and tail (reverse) reading.
6//! Port of Go's `pkg/util/readutil/readutil.go`.
7//!
8//! Provides line-based file reading with support for line counts,
9//! byte limits, line skipping, and progressive tail reading.
10
11
12use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom};
13
14/// Stop reason: beginning of file reached.
15pub const STOP_REASON_BOF: &str = "bof";
16/// Stop reason: end of file reached.
17pub const STOP_REASON_EOF: &str = "eof";
18/// Stop reason: byte read limit reached.
19pub const STOP_REASON_READ_LIMIT: &str = "read_limit";
20
21/// Read lines forward from a reader.
22///
23/// - `line_count`: max lines to return (0 = unlimited)
24/// - `skip_lines`: number of initial lines to skip
25/// - `read_limit`: max bytes to read (0 = unlimited)
26///
27/// Returns `(lines, stop_reason)`.
28pub fn read_lines<R: Read>(
29    reader: R,
30    line_count: usize,
31    skip_lines: usize,
32    read_limit: usize,
33) -> io::Result<(Vec<String>, String)> {
34    let mut buf_reader = BufReader::new(reader);
35    let mut lines = Vec::new();
36    let mut bytes_read = 0usize;
37    let mut skipped = 0usize;
38
39    loop {
40        let mut line = String::new();
41        match buf_reader.read_line(&mut line) {
42            Ok(0) => return Ok((lines, STOP_REASON_EOF.to_string())),
43            Ok(n) => {
44                bytes_read += n;
45
46                if skipped < skip_lines {
47                    skipped += 1;
48                } else {
49                    lines.push(line.clone());
50                    if line_count > 0 && lines.len() >= line_count {
51                        return Ok((lines, String::new()));
52                    }
53                }
54
55                if read_limit > 0 && bytes_read >= read_limit {
56                    return Ok((lines, STOP_REASON_READ_LIMIT.to_string()));
57                }
58            }
59            Err(e) => return Err(e),
60        }
61    }
62}
63
64/// Find the byte offsets of the last N lines in a seekable reader.
65///
66/// Returns `(offsets, total_lines)`.
67/// If `keep_first` is true, always includes offset 0 (start of file).
68pub fn read_last_n_line_offsets<R: Read + Seek>(
69    rs: &mut R,
70    max_lines: usize,
71    keep_first: bool,
72) -> io::Result<(Vec<i64>, usize)> {
73    rs.seek(SeekFrom::Start(0))?;
74
75    let mut offsets: Vec<i64> = Vec::new();
76    let mut reader = BufReader::new(rs);
77    let mut current_pos: i64 = 0;
78    let mut total_lines = 0;
79
80    if keep_first {
81        offsets.push(0);
82        total_lines = 1;
83    }
84
85    loop {
86        let mut line = Vec::new();
87        match reader.read_until(b'\n', &mut line) {
88            Ok(0) => break,
89            Ok(n) => {
90                current_pos += n as i64;
91                offsets.push(current_pos);
92                total_lines += 1;
93                if offsets.len() > max_lines + 1 {
94                    offsets.remove(0);
95                }
96            }
97            Err(e) => return Err(e),
98        }
99    }
100
101    if !offsets.is_empty() {
102        offsets.pop();
103        total_lines -= 1;
104    }
105
106    Ok((offsets, total_lines))
107}
108
109/// Read the last `line_count` lines from a seekable reader,
110/// optionally skipping the last `line_offset` lines.
111///
112/// Returns `(lines, has_more)`.
113fn read_tail_lines_internal<R: Read + Seek>(
114    rs: &mut R,
115    line_count: usize,
116    line_offset: usize,
117    keep_first: bool,
118) -> io::Result<(Vec<String>, bool)> {
119    let max_offsets = line_count + line_offset;
120    let (offsets, total_lines) = read_last_n_line_offsets(rs, max_offsets, keep_first)?;
121
122    if total_lines <= line_offset {
123        return Ok((Vec::new(), false));
124    }
125
126    let lines_to_read = line_count.min(total_lines - line_offset);
127    let start_idx = offsets.len().saturating_sub(line_offset + lines_to_read);
128    let has_more = total_lines > line_count + line_offset;
129
130    rs.seek(SeekFrom::Start(offsets[start_idx] as u64))?;
131
132    let (lines, _) = read_lines(rs, lines_to_read, 0, 0)?;
133    Ok((lines, has_more))
134}
135
136/// Read the last `line_count` lines from a seekable reader with a byte limit.
137///
138/// Progressively reads larger sections from the end (starting at 1MB, doubling)
139/// until enough lines are found or the limit is reached.
140///
141/// Returns `(lines, stop_reason)`.
142pub fn read_tail_lines<R: Read + Seek>(
143    rs: &mut R,
144    total_size: u64,
145    line_count: usize,
146    line_offset: usize,
147    read_limit: u64,
148) -> io::Result<(Vec<String>, String)> {
149    if read_limit == 0 {
150        return Err(io::Error::new(
151            io::ErrorKind::InvalidInput,
152            format!("read_limit must be positive, got {}", read_limit),
153        ));
154    }
155
156    let mut read_bytes: u64 = (1024 * 1024).min(read_limit);
157
158    loop {
159        let start_pos = if total_size > read_bytes {
160            total_size - read_bytes
161        } else {
162            read_bytes = total_size;
163            0
164        };
165
166        rs.seek(SeekFrom::Start(start_pos))?;
167        let keep_first = start_pos == 0;
168
169        // Read a limited section
170        let mut section = vec![0u8; read_bytes as usize];
171        let actually_read = rs.read(&mut section)?;
172        section.truncate(actually_read);
173
174        let mut cursor = io::Cursor::new(section);
175        let (lines, has_more_in_window) =
176            read_tail_lines_internal(&mut cursor, line_count, line_offset, keep_first)?;
177
178        if lines.len() == line_count {
179            let has_more = start_pos > 0 || has_more_in_window;
180            if !has_more {
181                return Ok((lines, STOP_REASON_BOF.to_string()));
182            }
183            return Ok((lines, String::new()));
184        }
185
186        if read_bytes >= read_limit || read_bytes >= total_size {
187            if start_pos > 0 {
188                return Ok((lines, STOP_REASON_READ_LIMIT.to_string()));
189            }
190            return Ok((lines, STOP_REASON_BOF.to_string()));
191        }
192
193        read_bytes = (read_bytes * 2).min(read_limit);
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    // ---- read_lines ----
202
203    #[test]
204    fn test_read_lines_basic() {
205        let data = b"line1\nline2\nline3\n";
206        let (lines, reason) = read_lines(&data[..], 0, 0, 0).unwrap();
207        assert_eq!(lines.len(), 3);
208        assert_eq!(reason, STOP_REASON_EOF);
209    }
210
211    #[test]
212    fn test_read_lines_with_limit() {
213        let data = b"line1\nline2\nline3\n";
214        let (lines, reason) = read_lines(&data[..], 2, 0, 0).unwrap();
215        assert_eq!(lines.len(), 2);
216        assert_eq!(reason, ""); // Hit line limit, not EOF
217    }
218
219    #[test]
220    fn test_read_lines_with_skip() {
221        let data = b"skip1\nskip2\nkeep1\nkeep2\n";
222        let (lines, reason) = read_lines(&data[..], 0, 2, 0).unwrap();
223        assert_eq!(lines.len(), 2);
224        assert!(lines[0].contains("keep1"));
225        assert_eq!(reason, STOP_REASON_EOF);
226    }
227
228    #[test]
229    fn test_read_lines_byte_limit() {
230        let data = b"line1\nline2\nline3\n";
231        let (lines, reason) = read_lines(&data[..], 0, 0, 12).unwrap();
232        assert_eq!(reason, STOP_REASON_READ_LIMIT);
233        assert!(lines.len() >= 1);
234    }
235
236    #[test]
237    fn test_read_lines_empty() {
238        let data = b"";
239        let (lines, reason) = read_lines(&data[..], 0, 0, 0).unwrap();
240        assert!(lines.is_empty());
241        assert_eq!(reason, STOP_REASON_EOF);
242    }
243
244    // ---- read_last_n_line_offsets ----
245
246    #[test]
247    fn test_line_offsets_basic() {
248        let data = b"line1\nline2\nline3\n";
249        let mut cursor = io::Cursor::new(data.as_slice());
250        let (offsets, total) = read_last_n_line_offsets(&mut cursor, 10, false).unwrap();
251        // Without keep_first, offset 0 is not included.
252        // After reading all lines, the final EOF offset is popped.
253        assert_eq!(total, 2);
254        assert_eq!(offsets.len(), 2);
255        assert_eq!(offsets[0], 6); // "line2\n" starts at 6
256        assert_eq!(offsets[1], 12); // "line3\n" starts at 12
257    }
258
259    #[test]
260    fn test_line_offsets_keep_first() {
261        let data = b"line1\nline2\nline3\n";
262        let mut cursor = io::Cursor::new(data.as_slice());
263        let (offsets, total) = read_last_n_line_offsets(&mut cursor, 10, true).unwrap();
264        // keep_first adds offset 0 and total_lines=1 initially.
265        // 3 lines read, pop last → total = 1 + 3 - 1 = 3
266        assert_eq!(total, 3);
267        assert_eq!(offsets[0], 0); // keep_first entry
268        assert_eq!(offsets[1], 6);
269        assert_eq!(offsets[2], 12);
270    }
271
272    #[test]
273    fn test_line_offsets_max_lines() {
274        let data = b"1\n2\n3\n4\n5\n";
275        let mut cursor = io::Cursor::new(data.as_slice());
276        let (offsets, total) = read_last_n_line_offsets(&mut cursor, 2, false).unwrap();
277        // 5 lines read, pop last → total = 5 - 1 = 4
278        assert_eq!(total, 4);
279        assert_eq!(offsets.len(), 2); // Only last 2
280    }
281
282    // ---- read_tail_lines ----
283
284    #[test]
285    fn test_tail_lines_basic() {
286        let data = b"line1\nline2\nline3\nline4\nline5\n";
287        let mut cursor = io::Cursor::new(data.as_slice());
288        let size = data.len() as u64;
289        let (lines, reason) = read_tail_lines(&mut cursor, size, 3, 0, 1024 * 1024).unwrap();
290        assert_eq!(lines.len(), 3);
291        assert!(lines[0].contains("line3"));
292        assert!(lines[1].contains("line4"));
293        assert!(lines[2].contains("line5"));
294        assert_eq!(reason, ""); // has_more = true
295    }
296
297    #[test]
298    fn test_tail_lines_all() {
299        let data = b"line1\nline2\n";
300        let mut cursor = io::Cursor::new(data.as_slice());
301        let size = data.len() as u64;
302        let (lines, reason) = read_tail_lines(&mut cursor, size, 10, 0, 1024 * 1024).unwrap();
303        assert_eq!(lines.len(), 2);
304        assert_eq!(reason, STOP_REASON_BOF);
305    }
306
307    #[test]
308    fn test_tail_lines_with_offset() {
309        let data = b"line1\nline2\nline3\nline4\nline5\n";
310        let mut cursor = io::Cursor::new(data.as_slice());
311        let size = data.len() as u64;
312        let (lines, _) = read_tail_lines(&mut cursor, size, 2, 1, 1024 * 1024).unwrap();
313        assert_eq!(lines.len(), 2);
314        // Skipping last 1 line (line5), reading 2 lines (line3, line4)
315        assert!(lines[0].contains("line3"));
316        assert!(lines[1].contains("line4"));
317    }
318
319    #[test]
320    fn test_tail_lines_invalid_limit() {
321        let data = b"line1\n";
322        let mut cursor = io::Cursor::new(data.as_slice());
323        let result = read_tail_lines(&mut cursor, data.len() as u64, 1, 0, 0);
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_tail_lines_empty() {
329        let data = b"";
330        let mut cursor = io::Cursor::new(data.as_slice());
331        let (lines, reason) = read_tail_lines(&mut cursor, 0, 10, 0, 1024 * 1024).unwrap();
332        assert!(lines.is_empty());
333        assert_eq!(reason, STOP_REASON_BOF);
334    }
335}