1use super::{BackendKind, CacheError, CacheOp, CacheStats};
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug)]
16pub struct TextBackend {
17 path: PathBuf,
19 line_count: usize,
21}
22
23impl TextBackend {
24 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 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 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 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 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#[allow(clippy::cast_precision_loss)] fn 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 #[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 #[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 #[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 #[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 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 assert!(result.contains("> 2: bbb"));
384 assert!(result.contains(" 1: aaa"));
385 }
386
387 #[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); }
415
416 #[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 #[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}