Skip to main content

sqry_cli/output/
preview.rs

1//! Preview extraction for showing code context around symbol matches
2//!
3//! This module provides `PreviewExtractor` for extracting lines of source code
4//! context around matched symbols. It includes LRU caching for file contents
5//! and path validation for security.
6
7use 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
15/// Maximum file size for preview extraction (1MB)
16const MAX_PREVIEW_FILE_SIZE: u64 = 1_048_576;
17
18/// Default LRU cache capacity (number of files)
19const DEFAULT_CACHE_CAPACITY: usize = 10;
20
21/// Errors that can occur during path validation
22enum PathValidationError {
23    /// File does not exist
24    NotFound(PathBuf),
25    /// Path is outside workspace root
26    OutsideWorkspace,
27    /// Other error during validation
28    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/// Configuration for preview/context display
42#[derive(Debug, Clone)]
43pub struct PreviewConfig {
44    /// Number of context lines before match
45    pub lines_before: usize,
46    /// Number of context lines after match
47    pub lines_after: usize,
48    /// Maximum line length before truncation
49    pub max_line_length: usize,
50    /// Truncation indicator
51    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    /// Create a new preview config with specified context lines
67    #[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    /// Create preview config showing only matched line (no context)
77    #[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/// A single line with its line number
88#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
89pub struct NumberedLine {
90    /// 1-indexed line number
91    pub line_number: usize,
92    /// Line content (may be truncated)
93    pub content: String,
94}
95
96/// Extracted context lines for a symbol
97#[derive(Debug, Clone, Serialize)]
98pub struct ContextLines {
99    /// Lines before the match (in order, oldest first)
100    pub before: Vec<NumberedLine>,
101    /// The matched line itself
102    pub matched: NumberedLine,
103    /// Lines after the match (in order)
104    pub after: Vec<NumberedLine>,
105    /// Error message if extraction failed (None on success)
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub error: Option<String>,
108}
109
110impl ContextLines {
111    /// Create context lines from extracted content
112    #[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    /// Create an error context (for files that can't be read)
123    #[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    /// Check if this is an error result
137    #[must_use]
138    pub fn is_error(&self) -> bool {
139        self.error.is_some()
140    }
141
142    /// Get the error message if any
143    #[must_use]
144    pub fn error_message(&self) -> Option<&str> {
145        self.error.as_deref()
146    }
147
148    /// Format context as a single preview string (for CSV/TSV)
149    #[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/// Grouped context for multiple adjacent matches in same file
180#[derive(Debug, Clone, Serialize)]
181pub struct GroupedContext {
182    /// File path
183    pub file: PathBuf,
184    /// Continuous line range start
185    pub start_line: usize,
186    /// Continuous line range end
187    pub end_line: usize,
188    /// All lines in range with match markers
189    pub lines: Vec<GroupedLine>,
190    /// Error message if grouping failed
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub error: Option<String>,
193}
194
195/// A line within a grouped context
196#[derive(Debug, Clone, Serialize)]
197pub struct GroupedLine {
198    /// 1-indexed line number
199    pub line_number: usize,
200    /// Line content
201    pub content: String,
202    /// True if this line is a matched symbol location
203    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/// Match location for grouping
219#[derive(Debug, Clone)]
220pub struct MatchLocation {
221    /// File path
222    pub file: PathBuf,
223    /// 1-indexed line number
224    pub line: usize,
225}
226
227/// Extracts code context around symbol locations
228#[allow(dead_code)]
229pub struct PreviewExtractor {
230    config: PreviewConfig,
231    /// Cache of recently read files (LRU, max capacity files)
232    file_cache: LruCache<PathBuf, Vec<String>>,
233    /// Workspace root for path validation
234    workspace_root: PathBuf,
235}
236
237impl PreviewExtractor {
238    /// Create new extractor with config and workspace root
239    ///
240    /// # Panics
241    /// Panics if `DEFAULT_CACHE_CAPACITY` is zero.
242    #[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    /// Create new extractor with custom cache capacity
254    #[allow(dead_code)]
255    ///
256    /// # Panics
257    /// Panics if `capacity` is zero after clamping.
258    #[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    /// Extract context for a symbol at the given file and line
269    ///
270    /// # Arguments
271    /// * `file` - Path to the source file
272    /// * `line` - 1-indexed line number of the match
273    ///
274    /// # Returns
275    /// * `Ok(ContextLines)` - Extracted context (may contain error message)
276    ///
277    /// # Errors
278    /// Returns an error if the file cannot be read.
279    pub fn extract(&mut self, file: &Path, line: usize) -> Result<ContextLines> {
280        // Validate path is within workspace
281        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        // Check file size before reading
300        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        // Read file lines (from cache or disk)
312        let lines = self.get_file_lines(&canonical_path)?;
313
314        // Extract context
315        Ok(self.extract_context(&lines, line))
316    }
317
318    /// Extract context for multiple matches, grouping by file for efficiency
319    ///
320    /// Returns context for each match in the same order as input.
321    #[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    /// Extract grouped context for multiple adjacent matches in same file
330    ///
331    /// Merges overlapping context windows into continuous ranges.
332    pub fn extract_grouped(&mut self, matches: &[MatchLocation]) -> Vec<GroupedContext> {
333        // Group matches by file
334        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            // Sort lines
343            lines.sort_unstable();
344            lines.dedup();
345
346            // Validate and read file
347            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            // Merge overlapping ranges
403            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    /// Validate and canonicalize path, ensuring it's within workspace root
421    fn validate_path(&self, path: &Path) -> Result<PathBuf, PathValidationError> {
422        // Check if file exists first (to give proper error message)
423        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    /// Get file lines from cache or read from disk
451    fn get_file_lines(&mut self, path: &PathBuf) -> Result<Vec<String>> {
452        // Check cache first
453        if let Some(lines) = self.file_cache.get(path) {
454            return Ok(lines.clone());
455        }
456
457        // Read file with lossy UTF-8 conversion
458        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        // Cache the lines
465        self.file_cache.put(path.clone(), lines.clone());
466
467        Ok(lines)
468    }
469
470    /// Extract context lines around the given line number
471    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; // Convert to 0-indexed
481
482        // Calculate range for before lines
483        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        // Matched line
492        let matched = NumberedLine {
493            line_number: line,
494            content: self.truncate_line(&lines[line_idx]),
495        };
496
497        // Calculate range for after lines
498        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    /// Truncate a line if it exceeds max length
510    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    /// Merge adjacent line ranges based on context overlap
523    ///
524    /// Returns vector of (`start_line`, `end_line`, `matched_lines`)
525    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                // Check if this range overlaps or touches the previous one
539                // Adjacent if: last_end + 1 >= start (context windows touch or overlap)
540                if last.1 + 1 >= start || last.1 + context >= line.saturating_sub(context) {
541                    // Merge: extend the end and add the match line
542                    last.1 = last.1.max(end);
543                    last.2.push(line);
544                    continue;
545                }
546            }
547
548            // Start new group
549            groups.push((start, end, vec![line]));
550        }
551
552        groups
553    }
554
555    /// Create a grouped context from merged ranges
556    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    /// Clear the file cache
593    #[allow(dead_code)]
594    pub fn clear_cache(&mut self) {
595        self.file_cache.clear();
596    }
597
598    /// Get current cache size
599    #[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); // Only line 1 before
650        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); // Only line 5 after
667    }
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        // First read
723        let _ = extractor.extract(&file, 1).unwrap();
724        assert_eq!(extractor.cache_size(), 1);
725
726        // Second read (should hit cache)
727        let _ = extractor.extract(&file, 2).unwrap();
728        assert_eq!(extractor.cache_size(), 1); // Still 1 file cached
729    }
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        // Lines 5 and 7 are adjacent with 2-line context
744        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); // Should be merged into one group
757        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        // Lines 5 and 25 are far apart
773        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); // Should be separate groups
786    }
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}