1use anyhow::{Context, Result};
8use lru::LruCache;
9use serde::Serialize;
10use std::collections::HashMap;
11use std::fs;
12use std::num::NonZeroUsize;
13use std::path::{Path, PathBuf};
14
15const MAX_PREVIEW_FILE_SIZE: u64 = 1_048_576;
17
18const DEFAULT_CACHE_CAPACITY: usize = 10;
20
21enum PathValidationError {
23 NotFound(PathBuf),
25 OutsideWorkspace,
27 Other(String),
29}
30
31impl std::fmt::Display for PathValidationError {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::NotFound(path) => write!(f, "file not found: {}", path.display()),
35 Self::OutsideWorkspace => write!(f, "outside workspace root"),
36 Self::Other(msg) => write!(f, "{msg}"),
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct PreviewConfig {
44 pub lines_before: usize,
46 pub lines_after: usize,
48 pub max_line_length: usize,
50 pub truncation_marker: &'static str,
52}
53
54impl Default for PreviewConfig {
55 fn default() -> Self {
56 Self {
57 lines_before: 3,
58 lines_after: 3,
59 max_line_length: 120,
60 truncation_marker: "...",
61 }
62 }
63}
64
65impl PreviewConfig {
66 #[must_use]
68 pub fn new(lines: usize) -> Self {
69 Self {
70 lines_before: lines,
71 lines_after: lines,
72 ..Default::default()
73 }
74 }
75
76 #[must_use]
78 pub fn no_context() -> Self {
79 Self {
80 lines_before: 0,
81 lines_after: 0,
82 ..Default::default()
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
89pub struct NumberedLine {
90 pub line_number: usize,
92 pub content: String,
94}
95
96#[derive(Debug, Clone, Serialize)]
98pub struct ContextLines {
99 pub before: Vec<NumberedLine>,
101 pub matched: NumberedLine,
103 pub after: Vec<NumberedLine>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub error: Option<String>,
108}
109
110impl ContextLines {
111 #[must_use]
113 pub fn new(before: Vec<NumberedLine>, matched: NumberedLine, after: Vec<NumberedLine>) -> Self {
114 Self {
115 before,
116 matched,
117 after,
118 error: None,
119 }
120 }
121
122 #[must_use]
124 pub fn error(message: impl Into<String>) -> Self {
125 Self {
126 before: Vec::new(),
127 matched: NumberedLine {
128 line_number: 0,
129 content: String::new(),
130 },
131 after: Vec::new(),
132 error: Some(message.into()),
133 }
134 }
135
136 #[must_use]
138 pub fn is_error(&self) -> bool {
139 self.error.is_some()
140 }
141
142 #[must_use]
144 pub fn error_message(&self) -> Option<&str> {
145 self.error.as_deref()
146 }
147
148 #[must_use]
150 pub fn to_preview_string(&self, max_length: usize) -> String {
151 if let Some(err) = &self.error {
152 return err.clone();
153 }
154
155 let mut lines: Vec<String> = Vec::new();
156
157 for line in &self.before {
158 lines.push(format!(" {} | {}", line.line_number, line.content));
159 }
160
161 lines.push(format!(
162 "> {} | {}",
163 self.matched.line_number, self.matched.content
164 ));
165
166 for line in &self.after {
167 lines.push(format!(" {} | {}", line.line_number, line.content));
168 }
169
170 let result = lines.join("\n");
171 if result.len() > max_length {
172 format!("{}...", &result[..max_length.saturating_sub(3)])
173 } else {
174 result
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize)]
181pub struct GroupedContext {
182 pub file: PathBuf,
184 pub start_line: usize,
186 pub end_line: usize,
188 pub lines: Vec<GroupedLine>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub error: Option<String>,
193}
194
195#[derive(Debug, Clone, Serialize)]
197pub struct GroupedLine {
198 pub line_number: usize,
200 pub content: String,
202 pub is_match: bool,
204}
205
206impl GroupedContext {
207 pub fn error(file: PathBuf, message: impl Into<String>) -> Self {
208 Self {
209 file,
210 start_line: 0,
211 end_line: 0,
212 lines: Vec::new(),
213 error: Some(message.into()),
214 }
215 }
216}
217
218#[derive(Debug, Clone)]
220pub struct MatchLocation {
221 pub file: PathBuf,
223 pub line: usize,
225}
226
227#[allow(dead_code)]
229pub struct PreviewExtractor {
230 config: PreviewConfig,
231 file_cache: LruCache<PathBuf, Vec<String>>,
233 workspace_root: PathBuf,
235}
236
237impl PreviewExtractor {
238 #[must_use]
243 pub fn new(config: PreviewConfig, workspace_root: PathBuf) -> Self {
244 let capacity = NonZeroUsize::new(DEFAULT_CACHE_CAPACITY)
245 .expect("DEFAULT_CACHE_CAPACITY must be non-zero");
246 Self {
247 config,
248 file_cache: LruCache::new(capacity),
249 workspace_root,
250 }
251 }
252
253 #[allow(dead_code)]
255 #[must_use]
259 pub fn with_capacity(config: PreviewConfig, workspace_root: PathBuf, capacity: usize) -> Self {
260 let capacity = NonZeroUsize::new(capacity.max(1)).expect("capacity must be non-zero");
261 Self {
262 config,
263 file_cache: LruCache::new(capacity),
264 workspace_root,
265 }
266 }
267
268 pub fn extract(&mut self, file: &Path, line: usize) -> Result<ContextLines> {
280 let canonical_path = match self.validate_path(file) {
282 Ok(p) => p,
283 Err(PathValidationError::NotFound(path)) => {
284 return Ok(ContextLines::error(format!(
285 "[file not found: {}]",
286 path.display()
287 )));
288 }
289 Err(PathValidationError::OutsideWorkspace) => {
290 return Ok(ContextLines::error(
291 "[access denied: outside workspace root]",
292 ));
293 }
294 Err(PathValidationError::Other(msg)) => {
295 return Ok(ContextLines::error(format!("[access denied: {msg}]")));
296 }
297 };
298
299 let metadata = match fs::metadata(&canonical_path) {
301 Ok(m) => m,
302 Err(e) => {
303 return Ok(ContextLines::error(format!("[file error: {e}]")));
304 }
305 };
306
307 if metadata.len() > MAX_PREVIEW_FILE_SIZE {
308 return Ok(ContextLines::error("[file too large for preview]"));
309 }
310
311 let lines = self.get_file_lines(&canonical_path)?;
313
314 Ok(self.extract_context(&lines, line))
316 }
317
318 #[allow(dead_code)]
322 pub fn extract_batch(&mut self, matches: &[MatchLocation]) -> Vec<Result<ContextLines>> {
323 matches
324 .iter()
325 .map(|m| self.extract(&m.file, m.line))
326 .collect()
327 }
328
329 pub fn extract_grouped(&mut self, matches: &[MatchLocation]) -> Vec<GroupedContext> {
333 let mut by_file: HashMap<&Path, Vec<usize>> = HashMap::new();
335 for m in matches {
336 by_file.entry(m.file.as_path()).or_default().push(m.line);
337 }
338
339 let mut result = Vec::new();
340
341 for (file, mut lines) in by_file {
342 lines.sort_unstable();
344 lines.dedup();
345
346 let canonical_path = match self.validate_path(file) {
348 Ok(p) => p,
349 Err(PathValidationError::NotFound(path)) => {
350 result.push(GroupedContext::error(
351 file.to_path_buf(),
352 format!("[file not found: {}]", path.display()),
353 ));
354 continue;
355 }
356 Err(PathValidationError::OutsideWorkspace) => {
357 result.push(GroupedContext::error(
358 file.to_path_buf(),
359 "[access denied: outside workspace root]",
360 ));
361 continue;
362 }
363 Err(PathValidationError::Other(msg)) => {
364 result.push(GroupedContext::error(
365 file.to_path_buf(),
366 format!("[access denied: {msg}]"),
367 ));
368 continue;
369 }
370 };
371
372 let metadata = match fs::metadata(&canonical_path) {
373 Ok(m) => m,
374 Err(e) => {
375 result.push(GroupedContext::error(
376 file.to_path_buf(),
377 format!("[file error: {e}]"),
378 ));
379 continue;
380 }
381 };
382
383 if metadata.len() > MAX_PREVIEW_FILE_SIZE {
384 result.push(GroupedContext::error(
385 file.to_path_buf(),
386 "[file too large for preview]",
387 ));
388 continue;
389 }
390
391 let file_lines = match self.get_file_lines(&canonical_path) {
392 Ok(l) => l,
393 Err(e) => {
394 result.push(GroupedContext::error(
395 file.to_path_buf(),
396 format!("[file error: {e}]"),
397 ));
398 continue;
399 }
400 };
401
402 let groups = self.merge_adjacent_ranges(&lines);
404
405 for (start, end, match_lines) in groups {
406 let grouped = self.create_grouped_context(
407 file.to_path_buf(),
408 &file_lines,
409 start,
410 end,
411 &match_lines,
412 );
413 result.push(grouped);
414 }
415 }
416
417 result
418 }
419
420 fn validate_path(&self, path: &Path) -> Result<PathBuf, PathValidationError> {
422 if !path.exists() {
424 return Err(PathValidationError::NotFound(path.to_path_buf()));
425 }
426
427 let canonical = path
428 .canonicalize()
429 .with_context(|| format!("Cannot resolve path: {}", path.display()))
430 .map_err(|e| PathValidationError::Other(e.to_string()))?;
431
432 let workspace_canonical = self
433 .workspace_root
434 .canonicalize()
435 .with_context(|| {
436 format!(
437 "Cannot resolve workspace: {}",
438 self.workspace_root.display()
439 )
440 })
441 .map_err(|e| PathValidationError::Other(e.to_string()))?;
442
443 if !canonical.starts_with(&workspace_canonical) {
444 return Err(PathValidationError::OutsideWorkspace);
445 }
446
447 Ok(canonical)
448 }
449
450 fn get_file_lines(&mut self, path: &PathBuf) -> Result<Vec<String>> {
452 if let Some(lines) = self.file_cache.get(path) {
454 return Ok(lines.clone());
455 }
456
457 let content =
459 fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?;
460 let content = String::from_utf8_lossy(&content);
461
462 let lines: Vec<String> = content.lines().map(String::from).collect();
463
464 self.file_cache.put(path.clone(), lines.clone());
466
467 Ok(lines)
468 }
469
470 fn extract_context(&self, lines: &[String], line: usize) -> ContextLines {
472 if line == 0 || line > lines.len() {
473 return ContextLines::error(format!(
474 "[line {} out of bounds (file has {} lines)]",
475 line,
476 lines.len()
477 ));
478 }
479
480 let line_idx = line - 1; let before_start = line_idx.saturating_sub(self.config.lines_before);
484 let before: Vec<NumberedLine> = (before_start..line_idx)
485 .map(|i| NumberedLine {
486 line_number: i + 1,
487 content: self.truncate_line(&lines[i]),
488 })
489 .collect();
490
491 let matched = NumberedLine {
493 line_number: line,
494 content: self.truncate_line(&lines[line_idx]),
495 };
496
497 let after_end = (line_idx + 1 + self.config.lines_after).min(lines.len());
499 let after: Vec<NumberedLine> = ((line_idx + 1)..after_end)
500 .map(|i| NumberedLine {
501 line_number: i + 1,
502 content: self.truncate_line(&lines[i]),
503 })
504 .collect();
505
506 ContextLines::new(before, matched, after)
507 }
508
509 fn truncate_line(&self, line: &str) -> String {
511 if line.len() > self.config.max_line_length {
512 format!(
513 "{}{}",
514 &line[..self.config.max_line_length - self.config.truncation_marker.len()],
515 self.config.truncation_marker
516 )
517 } else {
518 line.to_string()
519 }
520 }
521
522 fn merge_adjacent_ranges(&self, lines: &[usize]) -> Vec<(usize, usize, Vec<usize>)> {
526 if lines.is_empty() {
527 return Vec::new();
528 }
529
530 let mut groups: Vec<(usize, usize, Vec<usize>)> = Vec::new();
531 let context = self.config.lines_before.max(self.config.lines_after);
532
533 for &line in lines {
534 let start = line.saturating_sub(self.config.lines_before);
535 let end = line + self.config.lines_after;
536
537 if let Some(last) = groups.last_mut() {
538 if last.1 + 1 >= start || last.1 + context >= line.saturating_sub(context) {
541 last.1 = last.1.max(end);
543 last.2.push(line);
544 continue;
545 }
546 }
547
548 groups.push((start, end, vec![line]));
550 }
551
552 groups
553 }
554
555 fn create_grouped_context(
557 &self,
558 file: PathBuf,
559 file_lines: &[String],
560 start: usize,
561 end: usize,
562 match_lines: &[usize],
563 ) -> GroupedContext {
564 let start = start.max(1);
565 let end = end.min(file_lines.len());
566
567 let lines: Vec<GroupedLine> = (start..=end)
568 .map(|line_num| {
569 let idx = line_num - 1;
570 let content = if idx < file_lines.len() {
571 self.truncate_line(&file_lines[idx])
572 } else {
573 String::new()
574 };
575 GroupedLine {
576 line_number: line_num,
577 content,
578 is_match: match_lines.contains(&line_num),
579 }
580 })
581 .collect();
582
583 GroupedContext {
584 file,
585 start_line: start,
586 end_line: end,
587 lines,
588 error: None,
589 }
590 }
591
592 #[allow(dead_code)]
594 pub fn clear_cache(&mut self) {
595 self.file_cache.clear();
596 }
597
598 #[allow(dead_code)]
600 #[must_use]
601 pub fn cache_size(&self) -> usize {
602 self.file_cache.len()
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use std::fs::File;
610 use std::io::Write;
611 use tempfile::TempDir;
612
613 fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
614 let path = dir.path().join(name);
615 let mut file = File::create(&path).unwrap();
616 file.write_all(content.as_bytes()).unwrap();
617 path
618 }
619
620 #[test]
621 fn test_preview_extract_basic() {
622 let tmp = TempDir::new().unwrap();
623 let content = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n";
624 let file = create_test_file(&tmp, "test.rs", content);
625
626 let config = PreviewConfig::new(2);
627 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
628
629 let ctx = extractor.extract(&file, 4).unwrap();
630 assert!(!ctx.is_error());
631 assert_eq!(ctx.matched.line_number, 4);
632 assert_eq!(ctx.matched.content, "line 4");
633 assert_eq!(ctx.before.len(), 2);
634 assert_eq!(ctx.after.len(), 2);
635 }
636
637 #[test]
638 fn test_preview_extract_at_file_start() {
639 let tmp = TempDir::new().unwrap();
640 let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
641 let file = create_test_file(&tmp, "test.rs", content);
642
643 let config = PreviewConfig::new(3);
644 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
645
646 let ctx = extractor.extract(&file, 2).unwrap();
647 assert!(!ctx.is_error());
648 assert_eq!(ctx.matched.line_number, 2);
649 assert_eq!(ctx.before.len(), 1); assert_eq!(ctx.after.len(), 3);
651 }
652
653 #[test]
654 fn test_preview_extract_at_file_end() {
655 let tmp = TempDir::new().unwrap();
656 let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
657 let file = create_test_file(&tmp, "test.rs", content);
658
659 let config = PreviewConfig::new(3);
660 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
661
662 let ctx = extractor.extract(&file, 4).unwrap();
663 assert!(!ctx.is_error());
664 assert_eq!(ctx.matched.line_number, 4);
665 assert_eq!(ctx.before.len(), 3);
666 assert_eq!(ctx.after.len(), 1); }
668
669 #[test]
670 fn test_preview_truncate_long_line() {
671 let tmp = TempDir::new().unwrap();
672 let long_line = "a".repeat(200);
673 let content = format!("short\n{}\nshort", long_line);
674 let file = create_test_file(&tmp, "test.rs", &content);
675
676 let mut config = PreviewConfig::new(1);
677 config.max_line_length = 50;
678 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
679
680 let ctx = extractor.extract(&file, 2).unwrap();
681 assert!(!ctx.is_error());
682 assert!(ctx.matched.content.len() <= 50);
683 assert!(ctx.matched.content.ends_with("..."));
684 }
685
686 #[test]
687 fn test_preview_missing_file() {
688 let tmp = TempDir::new().unwrap();
689 let file = tmp.path().join("nonexistent.rs");
690
691 let config = PreviewConfig::new(3);
692 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
693
694 let ctx = extractor.extract(&file, 1).unwrap();
695 assert!(ctx.is_error());
696 assert!(ctx.error_message().unwrap().contains("file not found"));
697 }
698
699 #[test]
700 fn test_preview_line_out_of_bounds() {
701 let tmp = TempDir::new().unwrap();
702 let content = "line 1\nline 2\nline 3\n";
703 let file = create_test_file(&tmp, "test.rs", content);
704
705 let config = PreviewConfig::new(3);
706 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
707
708 let ctx = extractor.extract(&file, 100).unwrap();
709 assert!(ctx.is_error());
710 assert!(ctx.error_message().unwrap().contains("out of bounds"));
711 }
712
713 #[test]
714 fn test_preview_lru_cache_hit() {
715 let tmp = TempDir::new().unwrap();
716 let content = "line 1\nline 2\nline 3\n";
717 let file = create_test_file(&tmp, "test.rs", content);
718
719 let config = PreviewConfig::new(1);
720 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
721
722 let _ = extractor.extract(&file, 1).unwrap();
724 assert_eq!(extractor.cache_size(), 1);
725
726 let _ = extractor.extract(&file, 2).unwrap();
728 assert_eq!(extractor.cache_size(), 1); }
730
731 #[test]
732 fn test_preview_adjacent_matches_merged() {
733 let tmp = TempDir::new().unwrap();
734 let content = (1..=20)
735 .map(|i| format!("line {}", i))
736 .collect::<Vec<_>>()
737 .join("\n");
738 let file = create_test_file(&tmp, "test.rs", &content);
739
740 let config = PreviewConfig::new(2);
741 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
742
743 let matches = vec![
745 MatchLocation {
746 file: file.clone(),
747 line: 5,
748 },
749 MatchLocation {
750 file: file.clone(),
751 line: 7,
752 },
753 ];
754
755 let groups = extractor.extract_grouped(&matches);
756 assert_eq!(groups.len(), 1); assert!(groups[0].lines.iter().filter(|l| l.is_match).count() == 2);
758 }
759
760 #[test]
761 fn test_preview_non_adjacent_separate() {
762 let tmp = TempDir::new().unwrap();
763 let content = (1..=30)
764 .map(|i| format!("line {}", i))
765 .collect::<Vec<_>>()
766 .join("\n");
767 let file = create_test_file(&tmp, "test.rs", &content);
768
769 let config = PreviewConfig::new(2);
770 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
771
772 let matches = vec![
774 MatchLocation {
775 file: file.clone(),
776 line: 5,
777 },
778 MatchLocation {
779 file: file.clone(),
780 line: 25,
781 },
782 ];
783
784 let groups = extractor.extract_grouped(&matches);
785 assert_eq!(groups.len(), 2); }
787
788 #[test]
789 fn test_preview_no_context() {
790 let tmp = TempDir::new().unwrap();
791 let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
792 let file = create_test_file(&tmp, "test.rs", content);
793
794 let config = PreviewConfig::no_context();
795 let mut extractor = PreviewExtractor::new(config, tmp.path().to_path_buf());
796
797 let ctx = extractor.extract(&file, 3).unwrap();
798 assert!(!ctx.is_error());
799 assert_eq!(ctx.matched.line_number, 3);
800 assert_eq!(ctx.before.len(), 0);
801 assert_eq!(ctx.after.len(), 0);
802 }
803
804 #[test]
805 fn test_context_lines_to_preview_string() {
806 let ctx = ContextLines::new(
807 vec![NumberedLine {
808 line_number: 1,
809 content: "before".to_string(),
810 }],
811 NumberedLine {
812 line_number: 2,
813 content: "match".to_string(),
814 },
815 vec![NumberedLine {
816 line_number: 3,
817 content: "after".to_string(),
818 }],
819 );
820
821 let preview = ctx.to_preview_string(1000);
822 assert!(preview.contains("> 2 | match"));
823 assert!(preview.contains(" 1 | before"));
824 assert!(preview.contains(" 3 | after"));
825 }
826}