Skip to main content

llm_stack/tool/cache/
text.rs

1//! Text file-backed cache backend.
2//!
3//! Stores tool output as a plain text file with line-oriented operations.
4//! Uses buffered reads for efficient access to large files.
5
6use super::{BackendKind, CacheError, CacheOp, CacheStats};
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10
11/// A cache backend backed by a plain text file.
12///
13/// Supports line-oriented operations: read ranges, grep, head, tail, stats.
14/// The file is written once at creation and read on each operation.
15#[derive(Debug)]
16pub struct TextBackend {
17    /// Path to the backing file.
18    path: PathBuf,
19    /// Cached line count (computed on first access or at store time).
20    line_count: usize,
21}
22
23impl TextBackend {
24    /// Write content to a file and create a backend for it.
25    ///
26    /// # Errors
27    ///
28    /// Returns `CacheError::Io` if the file can't be written.
29    pub fn store(content: &str, path: &Path) -> Result<Self, CacheError> {
30        fs::write(path, content)?;
31        let line_count = content.lines().count();
32        Ok(Self {
33            path: path.to_path_buf(),
34            line_count,
35        })
36    }
37
38    /// Open an existing text cache file.
39    ///
40    /// # Errors
41    ///
42    /// Returns `CacheError::Io` if the file can't be read.
43    pub fn open(path: &Path) -> Result<Self, CacheError> {
44        let file = fs::File::open(path)?;
45        let reader = BufReader::new(file);
46        let line_count = reader.lines().count();
47        Ok(Self {
48            path: path.to_path_buf(),
49            line_count,
50        })
51    }
52
53    fn read_lines(&self) -> Result<Vec<String>, CacheError> {
54        let file = fs::File::open(&self.path)?;
55        let reader = BufReader::new(file);
56        reader
57            .lines()
58            .collect::<Result<Vec<_>, _>>()
59            .map_err(CacheError::from)
60    }
61
62    fn execute_read(&self, start: usize, end: usize) -> Result<String, CacheError> {
63        if start == 0 || end == 0 || start > end {
64            return Err(CacheError::OutOfBounds {
65                start,
66                end,
67                total: self.line_count,
68            });
69        }
70        if start > self.line_count {
71            return Err(CacheError::OutOfBounds {
72                start,
73                end,
74                total: self.line_count,
75            });
76        }
77
78        let lines = self.read_lines()?;
79        let clamped_end = end.min(self.line_count);
80        let selected: Vec<&str> = lines[start - 1..clamped_end]
81            .iter()
82            .map(String::as_str)
83            .collect();
84        Ok(selected.join("\n"))
85    }
86
87    fn execute_grep(&self, pattern: &str, context_lines: usize) -> Result<String, CacheError> {
88        let re =
89            regex::Regex::new(pattern).map_err(|e| CacheError::InvalidPattern(e.to_string()))?;
90
91        let lines = self.read_lines()?;
92        let total = lines.len();
93
94        // Find matching line indices
95        let matches: Vec<usize> = lines
96            .iter()
97            .enumerate()
98            .filter(|(_, line)| re.is_match(line))
99            .map(|(i, _)| i)
100            .collect();
101
102        if matches.is_empty() {
103            return Ok("(no matches)".to_string());
104        }
105
106        // Expand matches with context, merge overlapping ranges
107        let mut ranges: Vec<(usize, usize)> = Vec::new();
108        for &m in &matches {
109            let start = m.saturating_sub(context_lines);
110            let end = (m + context_lines).min(total.saturating_sub(1));
111            if let Some(last) = ranges.last_mut() {
112                if start <= last.1 + 1 {
113                    last.1 = end;
114                    continue;
115                }
116            }
117            ranges.push((start, end));
118        }
119
120        // Format output with line numbers
121        let mut output = Vec::new();
122        for (range_idx, &(start, end)) in ranges.iter().enumerate() {
123            if range_idx > 0 {
124                output.push("---".to_string());
125            }
126            for (i, line) in lines.iter().enumerate().take(end + 1).skip(start) {
127                let marker = if matches.contains(&i) { ">" } else { " " };
128                output.push(format!("{marker}{:>4}: {line}", i + 1));
129            }
130        }
131
132        output.push(format!("\n({} matches)", matches.len()));
133        Ok(output.join("\n"))
134    }
135
136    fn execute_head(&self, n: usize) -> Result<String, CacheError> {
137        let lines = self.read_lines()?;
138        let take = n.min(lines.len());
139        Ok(lines[..take].join("\n"))
140    }
141
142    fn execute_tail(&self, n: usize) -> Result<String, CacheError> {
143        let lines = self.read_lines()?;
144        let skip = lines.len().saturating_sub(n);
145        Ok(lines[skip..].join("\n"))
146    }
147}
148
149impl super::CacheBackend for TextBackend {
150    fn kind(&self) -> BackendKind {
151        BackendKind::Text
152    }
153
154    fn execute(&self, op: CacheOp) -> Result<String, CacheError> {
155        match op {
156            CacheOp::Read { start, end } => self.execute_read(start, end),
157            CacheOp::Grep {
158                pattern,
159                context_lines,
160            } => self.execute_grep(&pattern, context_lines),
161            CacheOp::Head { lines } => self.execute_head(lines),
162            CacheOp::Tail { lines } => self.execute_tail(lines),
163            CacheOp::Stats => {
164                let stats = self.stats()?;
165                Ok(stats.summary)
166            }
167        }
168    }
169
170    fn stats(&self) -> Result<CacheStats, CacheError> {
171        let meta = fs::metadata(&self.path)?;
172        let disk_bytes = meta.len();
173        let summary = format!("{} lines, {}", self.line_count, format_bytes(disk_bytes));
174        Ok(CacheStats {
175            line_count: self.line_count,
176            disk_bytes,
177            summary,
178        })
179    }
180
181    fn preview(&self, max_lines: usize) -> Result<String, CacheError> {
182        self.execute_head(max_lines)
183    }
184
185    fn disk_bytes(&self) -> Result<u64, CacheError> {
186        Ok(fs::metadata(&self.path)?.len())
187    }
188}
189
190/// Format bytes as a human-readable string.
191#[allow(clippy::cast_precision_loss)] // Acceptable: display-only, not used for math
192fn format_bytes(bytes: u64) -> String {
193    const KB: u64 = 1024;
194    const MB: u64 = 1024 * KB;
195
196    if bytes >= MB {
197        format!("{:.1} MB", bytes as f64 / MB as f64)
198    } else if bytes >= KB {
199        format!("{:.1} KB", bytes as f64 / KB as f64)
200    } else {
201        format!("{bytes} B")
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::tool::cache::CacheBackend;
209    use tempfile::tempdir;
210
211    fn sample_content() -> String {
212        (1..=100)
213            .map(|i| format!("line {i}: content for testing"))
214            .collect::<Vec<_>>()
215            .join("\n")
216    }
217
218    fn create_backend(dir: &Path, content: &str) -> TextBackend {
219        let path = dir.join("test.txt");
220        TextBackend::store(content, &path).unwrap()
221    }
222
223    // ── Store / open ────────────────────────────────────────────────
224
225    #[test]
226    fn test_store_creates_file() {
227        let dir = tempdir().unwrap();
228        let path = dir.path().join("test.txt");
229        let backend = TextBackend::store("hello\nworld", &path).unwrap();
230        assert_eq!(backend.line_count, 2);
231        assert!(path.exists());
232    }
233
234    #[test]
235    fn test_open_existing() {
236        let dir = tempdir().unwrap();
237        let path = dir.path().join("test.txt");
238        fs::write(&path, "a\nb\nc").unwrap();
239        let backend = TextBackend::open(&path).unwrap();
240        assert_eq!(backend.line_count, 3);
241    }
242
243    // ── Head / tail ─────────────────────────────────────────────────
244
245    #[test]
246    fn test_head() {
247        let dir = tempdir().unwrap();
248        let backend = create_backend(dir.path(), &sample_content());
249        let result = backend.execute_head(3).unwrap();
250        assert_eq!(
251            result,
252            "line 1: content for testing\nline 2: content for testing\nline 3: content for testing"
253        );
254    }
255
256    #[test]
257    fn test_head_more_than_available() {
258        let dir = tempdir().unwrap();
259        let backend = create_backend(dir.path(), "a\nb");
260        let result = backend.execute_head(10).unwrap();
261        assert_eq!(result, "a\nb");
262    }
263
264    #[test]
265    fn test_tail() {
266        let dir = tempdir().unwrap();
267        let backend = create_backend(dir.path(), &sample_content());
268        let result = backend.execute_tail(2).unwrap();
269        assert!(result.contains("line 99:"));
270        assert!(result.contains("line 100:"));
271    }
272
273    #[test]
274    fn test_tail_more_than_available() {
275        let dir = tempdir().unwrap();
276        let backend = create_backend(dir.path(), "a\nb");
277        let result = backend.execute_tail(10).unwrap();
278        assert_eq!(result, "a\nb");
279    }
280
281    // ── Read range ──────────────────────────────────────────────────
282
283    #[test]
284    fn test_read_range() {
285        let dir = tempdir().unwrap();
286        let backend = create_backend(dir.path(), &sample_content());
287        let result = backend.execute_read(5, 7).unwrap();
288        assert!(result.contains("line 5:"));
289        assert!(result.contains("line 6:"));
290        assert!(result.contains("line 7:"));
291        assert!(!result.contains("line 4:"));
292        assert!(!result.contains("line 8:"));
293    }
294
295    #[test]
296    fn test_read_range_clamps_end() {
297        let dir = tempdir().unwrap();
298        let backend = create_backend(dir.path(), "a\nb\nc");
299        let result = backend.execute_read(2, 100).unwrap();
300        assert_eq!(result, "b\nc");
301    }
302
303    #[test]
304    fn test_read_range_invalid_zero() {
305        let dir = tempdir().unwrap();
306        let backend = create_backend(dir.path(), "a\nb");
307        assert!(matches!(
308            backend.execute_read(0, 1),
309            Err(CacheError::OutOfBounds { .. })
310        ));
311    }
312
313    #[test]
314    fn test_read_range_start_past_end() {
315        let dir = tempdir().unwrap();
316        let backend = create_backend(dir.path(), "a\nb");
317        assert!(matches!(
318            backend.execute_read(5, 3),
319            Err(CacheError::OutOfBounds { .. })
320        ));
321    }
322
323    #[test]
324    fn test_read_range_start_past_total() {
325        let dir = tempdir().unwrap();
326        let backend = create_backend(dir.path(), "a\nb");
327        assert!(matches!(
328            backend.execute_read(10, 20),
329            Err(CacheError::OutOfBounds { .. })
330        ));
331    }
332
333    // ── Grep ────────────────────────────────────────────────────────
334
335    #[test]
336    fn test_grep_finds_matches() {
337        let dir = tempdir().unwrap();
338        let content = "apple\nbanana\napricot\nblueberry\navocado";
339        let backend = create_backend(dir.path(), content);
340        let result = backend.execute_grep("^a", 0).unwrap();
341        assert!(result.contains("apple"));
342        assert!(result.contains("apricot"));
343        assert!(result.contains("avocado"));
344        assert!(result.contains("(3 matches)"));
345    }
346
347    #[test]
348    fn test_grep_with_context() {
349        let dir = tempdir().unwrap();
350        let content = "aaa\nbbb\nccc\nddd\neee";
351        let backend = create_backend(dir.path(), content);
352        let result = backend.execute_grep("ccc", 1).unwrap();
353        // Should include bbb (context before) and ddd (context after)
354        assert!(result.contains("bbb"));
355        assert!(result.contains("ccc"));
356        assert!(result.contains("ddd"));
357        assert!(result.contains("(1 matches)"));
358    }
359
360    #[test]
361    fn test_grep_no_matches() {
362        let dir = tempdir().unwrap();
363        let backend = create_backend(dir.path(), "hello\nworld");
364        let result = backend.execute_grep("zzz", 0).unwrap();
365        assert_eq!(result, "(no matches)");
366    }
367
368    #[test]
369    fn test_grep_invalid_pattern() {
370        let dir = tempdir().unwrap();
371        let backend = create_backend(dir.path(), "test");
372        let result = backend.execute_grep("[invalid", 0);
373        assert!(matches!(result, Err(CacheError::InvalidPattern(_))));
374    }
375
376    #[test]
377    fn test_grep_marks_matching_lines() {
378        let dir = tempdir().unwrap();
379        let content = "aaa\nbbb\nccc";
380        let backend = create_backend(dir.path(), content);
381        let result = backend.execute_grep("bbb", 1).unwrap();
382        // Matching line gets ">" prefix, context lines get " " prefix
383        assert!(result.contains(">   2: bbb"));
384        assert!(result.contains("    1: aaa"));
385    }
386
387    // ── Stats / preview / disk_bytes ────────────────────────────────
388
389    #[test]
390    fn test_stats() {
391        let dir = tempdir().unwrap();
392        let backend = create_backend(dir.path(), &sample_content());
393        let stats = backend.stats().unwrap();
394        assert_eq!(stats.line_count, 100);
395        assert!(stats.disk_bytes > 0);
396        assert!(stats.summary.contains("100 lines"));
397    }
398
399    #[test]
400    fn test_preview() {
401        let dir = tempdir().unwrap();
402        let backend = create_backend(dir.path(), &sample_content());
403        let preview = backend.preview(5).unwrap();
404        let lines: Vec<&str> = preview.lines().collect();
405        assert_eq!(lines.len(), 5);
406    }
407
408    #[test]
409    fn test_disk_bytes() {
410        let dir = tempdir().unwrap();
411        let backend = create_backend(dir.path(), "hello world");
412        let bytes = backend.disk_bytes().unwrap();
413        assert_eq!(bytes, 11); // "hello world" is 11 bytes
414    }
415
416    // ── CacheBackend trait dispatch ─────────────────────────────────
417
418    #[test]
419    fn test_trait_dispatch_head() {
420        let dir = tempdir().unwrap();
421        let backend = create_backend(dir.path(), "a\nb\nc");
422        let result = backend.execute(CacheOp::Head { lines: 2 }).unwrap();
423        assert_eq!(result, "a\nb");
424    }
425
426    #[test]
427    fn test_trait_dispatch_tail() {
428        let dir = tempdir().unwrap();
429        let backend = create_backend(dir.path(), "a\nb\nc");
430        let result = backend.execute(CacheOp::Tail { lines: 2 }).unwrap();
431        assert_eq!(result, "b\nc");
432    }
433
434    #[test]
435    fn test_trait_dispatch_stats() {
436        let dir = tempdir().unwrap();
437        let backend = create_backend(dir.path(), "a\nb\nc");
438        let result = backend.execute(CacheOp::Stats).unwrap();
439        assert!(result.contains("3 lines"));
440    }
441
442    #[test]
443    fn test_kind() {
444        let dir = tempdir().unwrap();
445        let backend = create_backend(dir.path(), "test");
446        assert_eq!(backend.kind(), BackendKind::Text);
447    }
448
449    // ── format_bytes ────────────────────────────────────────────────
450
451    #[test]
452    fn test_format_bytes() {
453        assert_eq!(format_bytes(0), "0 B");
454        assert_eq!(format_bytes(500), "500 B");
455        assert_eq!(format_bytes(1024), "1.0 KB");
456        assert_eq!(format_bytes(1536), "1.5 KB");
457        assert_eq!(format_bytes(1_048_576), "1.0 MB");
458        assert_eq!(format_bytes(2_621_440), "2.5 MB");
459    }
460}