use std::fs; use std::path::Path; use tempfile::TempDir; use fff_search::FilePickerOptions; use fff_search::file_picker::FilePicker; use fff_search::grep::{GrepMode, GrepSearchOptions, parse_grep_query}; /// Build a batch of test files and return a FilePicker with them indexed. fn create_picker(base: &Path, specs: &[(&str, &str)]) -> FilePicker { for (rel, contents) in specs { let full_path = base.join(rel); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(&full_path, contents).unwrap(); } let mut picker = FilePicker::new(FilePickerOptions { base_path: base.to_string_lossy().to_string(), enable_mmap_cache: false, watch: false, ..Default::default() }) .expect("Failed to create FilePicker"); picker.collect_files().expect("Failed to collect files"); picker } /// Shorthand to build default options for plain text mode. fn plain_opts() -> GrepSearchOptions { GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, file_offset: 0, page_limit: 200, mode: GrepMode::PlainText, time_budget_ms: 0, before_context: 0, after_context: 0, classify_definitions: false, trim_whitespace: false, abort_signal: None, } } /// Shorthand to build default options for regex mode. fn regex_opts() -> GrepSearchOptions { GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, file_offset: 0, page_limit: 200, mode: GrepMode::Regex, time_budget_ms: 0, before_context: 0, after_context: 0, classify_definitions: false, trim_whitespace: false, abort_signal: None, } } /// Shorthand to build default options for fuzzy mode. fn fuzzy_opts() -> GrepSearchOptions { GrepSearchOptions { max_file_size: 10 * 1024 * 1024, max_matches_per_file: 200, smart_case: true, file_offset: 0, page_limit: 200, mode: GrepMode::Fuzzy, time_budget_ms: 0, before_context: 0, after_context: 0, classify_definitions: false, trim_whitespace: false, abort_signal: None, } } #[test] fn plain_text_finds_exact_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("hello.txt", "Hello, World!\nGoodbye, World!\n")], ); let parsed = parse_grep_query("Hello"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 1); assert!(result.matches[0].line_content.contains("Hello")); } #[test] fn plain_text_smart_case_insensitive() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "Hello World\nhello world\nHELLO WORLD\n")], ); // All lowercase query → smart case → case-insensitive let parsed = parse_grep_query("hello"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 3, "smart case should match all 3 lines" ); } #[test] fn plain_text_smart_case_sensitive_with_uppercase() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "Hello World\nhello world\nHELLO WORLD\n")], ); // Query has uppercase → smart case → case-sensitive let parsed = parse_grep_query("Hello"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 1, "uppercase in query should trigger case-sensitive" ); assert_eq!(result.matches[0].line_number, 1); } #[test] fn plain_text_regex_metacharacters_are_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "code.rs", "fn main() {\n println!(\"test\");\n}\nfn foo() {}\n", )], ); // In plain text mode, these regex metacharacters should be literal let parsed = parse_grep_query("fn main()"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 1); // Parentheses should NOT be treated as regex groups let parsed2 = parse_grep_query("(\"test\")"); let result2 = picker.grep(&parsed2, &plain_opts()); assert_eq!(result2.matches.len(), 1); assert_eq!(result2.matches[0].line_number, 2); } #[test] fn plain_text_dot_is_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "config.toml", "version = \"1.0\"\nname = \"foo\"\nversion_major = 1\n", )], ); // In plain text mode, dot should be literal, not "any char" let parsed = parse_grep_query("1.0"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 1, "dot should be literal, not 'any char'" ); assert!(result.matches[0].line_content.contains("1.0")); } #[test] fn plain_text_asterisk_is_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "doc.md", "Use **bold** text\nUse *italic* text\nUse normal text\n", )], ); let parsed = parse_grep_query("**bold**"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 1); } #[test] fn plain_text_backslash_is_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("paths.txt", "C:\\Users\\foo\\bar\n/home/user/bin\n")], ); let parsed = parse_grep_query("C:\\Users"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); } #[test] fn plain_text_across_multiple_files() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("a.txt", "use std::io;\nuse std::fs;\n"), ("b.txt", "use std::path;\nuse serde;\n"), ("c.txt", "no match here\n"), ], ); let parsed = parse_grep_query("use std"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 3); // Should match in files a.txt and b.txt assert_eq!(result.files.len(), 2); } #[test] fn plain_text_highlight_offsets_are_correct() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "foo bar foo baz foo\n")]); let parsed = parse_grep_query("foo"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); let m = &result.matches[0]; // "foo" appears at byte offsets 0, 8, 16 assert_eq!(m.match_byte_offsets.len(), 3); assert_eq!(m.match_byte_offsets[0], (0, 3)); assert_eq!(m.match_byte_offsets[1], (8, 11)); assert_eq!(m.match_byte_offsets[2], (16, 19)); assert_eq!(m.col, 0); } #[test] fn plain_text_empty_query_returns_no_content_matches() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "some content\n")]); let parsed = parse_grep_query(""); let result = picker.grep(&parsed, &plain_opts()); // Empty query in grep returns git-modified welcome state (no content matches) // Since our test files have no git status, we expect 0 matches assert_eq!(result.matches.len(), 0); } #[test] fn plain_text_binary_files_are_skipped() { let tmp = TempDir::new().unwrap(); let mut bin_content = b"match this text\n".to_vec(); bin_content.extend_from_slice(&[0u8; 100]); // NUL bytes make it binary bin_content.extend_from_slice(b"match this text\n"); // Initialize a git repo so the picker indexes binary-extension files // (marking them as binary) rather than skipping them entirely. std::process::Command::new("git") .args(["init", "--quiet"]) .current_dir(tmp.path()) .status() .expect("git init failed"); // Use a known binary extension so the picker's scan-time heuristic marks // it as binary without needing mutable post-hoc access. fs::write(tmp.path().join("binary.png"), &bin_content).unwrap(); // create_picker writes text.txt and then scans the directory, picking up // both binary.png (already on disk) and text.txt. let picker = create_picker(tmp.path(), &[("text.txt", "match this text\n")]); // binary.png should have been auto-detected as binary by extension heuristic let has_binary = picker .get_files() .iter() .any(|f| f.relative_path(&picker).contains("binary.png") && f.is_binary()); assert!(has_binary, "binary.png should be detected as binary"); let parsed = parse_grep_query("match this text"); let result = picker.grep(&parsed, &plain_opts()); // Only the text file should be searched, not the binary one assert_eq!(result.files.len(), 1); assert!(result.files[0].relative_path(&picker).contains("text.txt")); } #[test] fn binary_payload_after_long_ascii_header_is_detected() { // Mimics formats like Radiance .hdr / Apple bplist / Adobe .ai where the // first ~1KB is plain ASCII and the binary payload (NULs) starts later. // The legacy 512-byte sniff missed these; the bigram-build memchr scan // over the whole indexed buffer must catch them. use fff_search::file_picker::FFFMode; use fff_search::{SharedFilePicker, SharedFrecency}; use std::time::Duration; let tmp = TempDir::new().unwrap(); let base = tmp.path(); let mut content = Vec::new(); // 1 KiB of plain ASCII header — escapes any small fixed-window NUL sniff. content.extend(std::iter::repeat_n(b'A', 1024)); content.extend_from_slice(b"\nmatch this text\n"); // Binary payload: NUL bytes that prove the file is not text. content.extend(std::iter::repeat_n(0u8, 256)); content.extend_from_slice(b"\nmatch this text\n"); // Use a *text* extension so the scan-time heuristic does NOT pre-flag it. // Only the bigram-time content scan can mark it binary. fs::write(base.join("header.txt"), &content).unwrap(); fs::write(base.join("plain.txt"), b"match this text\n").unwrap(); let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( shared_picker.clone(), shared_frecency.clone(), FilePickerOptions { base_path: base.to_string_lossy().to_string(), enable_mmap_cache: false, enable_content_indexing: true, mode: FFFMode::Neovim, watch: false, ..Default::default() }, ) .expect("Failed to create FilePicker"); let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { std::thread::sleep(Duration::from_millis(25)); let ready = shared_picker .read() .ok() .and_then(|g| { g.as_ref() .map(|p| !p.is_scan_active() && p.bigram_index().is_some()) }) .unwrap_or(false); if ready { break; } assert!( std::time::Instant::now() < deadline, "Timed out waiting for bigram build" ); } let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); let was_flagged = picker .get_files() .iter() .any(|f| f.relative_path(picker).contains("header.txt") && f.is_binary()); assert!( was_flagged, "header.txt with NULs past 512 bytes must be flagged binary by the whole-buffer memchr scan" ); let parsed = parse_grep_query("match this text"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.files.len(), 1, "only plain.txt should be searched; header.txt must be skipped as binary" ); assert!( result.files[0].relative_path(picker).contains("plain.txt"), "the only match should come from plain.txt" ); } #[test] fn unknown_extension_binary_added_after_scan_is_reclassified() { // The initial-scan path runs detect_binary_content as part of bigram build, // but the watcher path used to fall back to extension-only triage and // missed binary files with unknown extensions like `.codex`. use fff_search::file_picker::FFFMode; use fff_search::{SharedFilePicker, SharedFrecency}; use std::time::Duration; let tmp = TempDir::new().unwrap(); let base = tmp.path(); // Seed one tracked text file so the initial scan has something to work with. fs::write(base.join("seed.txt"), b"seed\n").unwrap(); let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( shared_picker.clone(), shared_frecency.clone(), FilePickerOptions { base_path: base.to_string_lossy().to_string(), enable_mmap_cache: false, enable_content_indexing: true, mode: FFFMode::Neovim, watch: false, ..Default::default() }, ) .expect("Failed to create FilePicker"); let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { std::thread::sleep(Duration::from_millis(25)); let ready = shared_picker .read() .ok() .and_then(|g| { g.as_ref() .map(|p| !p.is_scan_active() && p.bigram_index().is_some()) }) .unwrap_or(false); if ready { break; } assert!( std::time::Instant::now() < deadline, "Timed out waiting for bigram build" ); } // Drop the file on disk after indexing finished, then announce it through // the watcher entry point. `.codex` is intentionally not in the extension // allow-list — only a content sniff can flag it. let mut payload = vec![0x03u8, 0x00, 0x04, 0x05]; payload.extend(std::iter::repeat_n(0u8, 256)); let new_path = base.join("snapshot.codex"); fs::write(&new_path, &payload).unwrap(); { let mut guard = shared_picker.write().unwrap(); let picker = guard.as_mut().unwrap(); assert!( picker.handle_create_or_modify(&new_path).is_some(), "handle_create_or_modify must accept the new file" ); } let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); let was_flagged = picker .get_files() .iter() .any(|f| f.relative_path(picker).contains("snapshot.codex") && f.is_binary()); assert!( was_flagged, "snapshot.codex must be flagged binary when added via the watcher path" ); } #[test] fn text_file_modified_to_binary_is_reclassified() { // A file that started life as text and later got rewritten with NUL bytes // (e.g. a generator overwrote a .log) must lose its text classification. use fff_search::file_picker::FFFMode; use fff_search::{SharedFilePicker, SharedFrecency}; use std::time::Duration; let tmp = TempDir::new().unwrap(); let base = tmp.path(); // Start as plain text with a known extension. fs::write(base.join("notes.txt"), b"hello world\n").unwrap(); let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( shared_picker.clone(), shared_frecency.clone(), FilePickerOptions { base_path: base.to_string_lossy().to_string(), enable_mmap_cache: false, enable_content_indexing: true, mode: FFFMode::Neovim, watch: false, ..Default::default() }, ) .expect("Failed to create FilePicker"); let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { std::thread::sleep(Duration::from_millis(25)); let ready = shared_picker .read() .ok() .and_then(|g| { g.as_ref() .map(|p| !p.is_scan_active() && p.bigram_index().is_some()) }) .unwrap_or(false); if ready { break; } assert!( std::time::Instant::now() < deadline, "Timed out waiting for bigram build" ); } // Sanity: it's text right now. { let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); let is_text = picker .get_files() .iter() .any(|f| f.relative_path(picker).contains("notes.txt") && !f.is_binary()); assert!(is_text, "notes.txt should start as text"); } // Overwrite with binary content and replay through the watcher entry point. // Bump mtime so update_metadata records it as a real change. std::thread::sleep(Duration::from_secs(1)); let mut payload = b"header text\n".to_vec(); payload.extend(std::iter::repeat_n(0u8, 256)); fs::write(base.join("notes.txt"), &payload).unwrap(); { let mut guard = shared_picker.write().unwrap(); let picker = guard.as_mut().unwrap(); assert!( picker .handle_create_or_modify(base.join("notes.txt")) .is_some(), "handle_create_or_modify must succeed for the modify case" ); } let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); let now_binary = picker .get_files() .iter() .any(|f| f.relative_path(picker).contains("notes.txt") && f.is_binary()); assert!( now_binary, "notes.txt must flip to binary after being overwritten with NULs" ); } #[test] fn large_unknown_extension_binary_is_classified_at_scan_time() { // Files larger than MAX_INDEXABLE_FILE_SIZE never enter build_bigram_index, // so without a separate header sniff they default to is_binary=false and // pollute grep results with NUL-laden lines (e.g. a committed ELF blob // named `codex_view` with no extension). use fff_search::file_picker::FFFMode; use fff_search::grep::{GrepSearchOptions, parse_grep_query}; use fff_search::{SharedFilePicker, SharedFrecency}; use std::time::Duration; let tmp = TempDir::new().unwrap(); let base = tmp.path(); // 3 MiB: above the 2 MiB bigram cap and below the 10 MiB grep cap. // ELF-like header with NULs at the very start, then ASCII filler so a // grep for "match this text" would otherwise return polluted lines. let mut blob = Vec::new(); blob.extend_from_slice(b"\x7fELF\x02\x01\x01\x00"); blob.extend(std::iter::repeat_n(0u8, 256)); blob.extend_from_slice(b"\nmatch this text\n"); blob.extend(std::iter::repeat_n(b'A', 3 * 1024 * 1024)); blob.extend_from_slice(b"\nmatch this text\n"); fs::write(base.join("codex_view"), &blob).unwrap(); fs::write(base.join("plain.txt"), b"match this text\n").unwrap(); let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( shared_picker.clone(), shared_frecency.clone(), FilePickerOptions { base_path: base.to_string_lossy().to_string(), enable_mmap_cache: false, enable_content_indexing: true, mode: FFFMode::Neovim, watch: false, ..Default::default() }, ) .expect("Failed to create FilePicker"); let deadline = std::time::Instant::now() + Duration::from_secs(15); loop { std::thread::sleep(Duration::from_millis(25)); let ready = shared_picker .read() .ok() .and_then(|g| { g.as_ref() .map(|p| !p.is_scan_active() && p.bigram_index().is_some()) }) .unwrap_or(false); if ready { break; } assert!( std::time::Instant::now() < deadline, "Timed out waiting for bigram build" ); } let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); let was_flagged = picker .get_files() .iter() .any(|f| f.relative_path(picker).contains("codex_view") && f.is_binary()); assert!( was_flagged, "large no-extension binary must be flagged via the header sniff" ); let parsed = parse_grep_query("match this text"); let opts = GrepSearchOptions { max_file_size: 10 * 1024 * 1024, ..plain_opts() }; let result = picker.grep(&parsed, &opts); assert_eq!( result.files.len(), 1, "only plain.txt should be searched; codex_view must be skipped as binary" ); assert!( result.files[0].relative_path(picker).contains("plain.txt"), "the only match should come from plain.txt" ); } #[test] fn large_binary_with_nuls_past_header_is_classified() { // Guards the streaming sniff: a >2 MB file that is pure ASCII well past any // fixed header window (the old code only checked the first 8 KB) but has // NULs deeper in. Grep reads the whole file up to max_file_size, so the // detector must scan the same range or the binary tail leaks as "text". use fff_search::file_picker::FFFMode; use fff_search::grep::{GrepSearchOptions, parse_grep_query}; use fff_search::{SharedFilePicker, SharedFrecency}; use std::time::Duration; let tmp = TempDir::new().unwrap(); let base = tmp.path(); // 1 MiB of clean ASCII (with a grep marker) — dwarfs any header sniff — // then NUL bytes, keeping the total above the 2 MiB non-indexable cap. let mut blob = Vec::new(); blob.extend_from_slice(b"match this text\n"); blob.extend(std::iter::repeat_n(b'A', 1024 * 1024)); blob.extend_from_slice(b"match this text\n"); blob.extend(std::iter::repeat_n(0u8, 1024 * 1024 + 4096)); // NULs start ~1 MiB in blob.extend_from_slice(b"match this text\n"); assert!(blob.len() > 2 * 1024 * 1024); fs::write(base.join("late_nul.dat"), &blob).unwrap(); fs::write(base.join("plain.txt"), b"match this text\n").unwrap(); let shared_picker = SharedFilePicker::default(); let shared_frecency = SharedFrecency::default(); FilePicker::new_with_shared_state( shared_picker.clone(), shared_frecency.clone(), FilePickerOptions { base_path: base.to_string_lossy().to_string(), enable_mmap_cache: false, enable_content_indexing: true, mode: FFFMode::Neovim, watch: false, ..Default::default() }, ) .expect("Failed to create FilePicker"); let deadline = std::time::Instant::now() + Duration::from_secs(15); loop { std::thread::sleep(Duration::from_millis(25)); let ready = shared_picker .read() .ok() .and_then(|g| { g.as_ref() .map(|p| !p.is_scan_active() && p.bigram_index().is_some()) }) .unwrap_or(false); if ready { break; } assert!( std::time::Instant::now() < deadline, "Timed out waiting for bigram build" ); } let guard = shared_picker.read().unwrap(); let picker = guard.as_ref().unwrap(); let flagged = picker .get_files() .iter() .any(|f| f.relative_path(picker).contains("late_nul.dat") && f.is_binary()); assert!( flagged, "NULs past the 8 KB header window must still be detected by the streaming scan" ); let parsed = parse_grep_query("match this text"); let opts = GrepSearchOptions { max_file_size: 10 * 1024 * 1024, ..plain_opts() }; let result = picker.grep(&parsed, &opts); assert_eq!( result.files.len(), 1, "only plain.txt should match; late_nul.dat must be skipped as binary" ); assert!(result.files[0].relative_path(picker).contains("plain.txt")); } #[test] fn plain_text_max_matches_per_file() { let tmp = TempDir::new().unwrap(); let mut content = String::new(); for i in 0..50 { content.push_str(&format!("line {} match_target\n", i)); } fs::write(tmp.path().join("many.txt"), &content).unwrap(); let picker = create_picker(tmp.path(), &[("many.txt", &content)]); let mut opts = plain_opts(); opts.max_matches_per_file = 5; let parsed = parse_grep_query("match_target"); let result = picker.grep(&parsed, &opts); assert_eq!( result.matches.len(), 5, "should cap at max_matches_per_file" ); } #[test] fn plain_text_page_limit() { let tmp = TempDir::new().unwrap(); let mut content = String::new(); for i in 0..100 { content.push_str(&format!("line {} target\n", i)); } fs::write(tmp.path().join("big.txt"), &content).unwrap(); let picker = create_picker(tmp.path(), &[("big.txt", &content)]); let mut opts = plain_opts(); opts.page_limit = 10; let parsed = parse_grep_query("target"); let result = picker.grep(&parsed, &opts); // page_limit is a soft minimum: we always finish the current file, so we // get at least page_limit matches (no data loss) and at most // max_matches_per_file (200) from a single file. assert!( result.matches.len() >= opts.page_limit, "should return at least page_limit matches: got {}", result.matches.len() ); assert!( result.matches.len() <= opts.max_matches_per_file, "should never exceed max_matches_per_file: got {}", result.matches.len() ); // Single file with 100 lines all matching — all should be returned. assert_eq!(result.matches.len(), 100, "all 100 lines must be returned"); } #[test] fn plain_text_file_offset_pagination() { let tmp = TempDir::new().unwrap(); // Create many files (1 match per file) so file-based pagination exercises // offset tracking across files with and without matches. let specs: Vec<(String, String)> = (0..20) .map(|i| { ( format!("file_{:02}.txt", i), format!("unique_token_{}\n", i), ) }) .collect(); let spec_refs: Vec<(&str, &str)> = specs .iter() .map(|(a, b)| (a.as_str(), b.as_str())) .collect(); let picker = create_picker(tmp.path(), &spec_refs); let mut opts = plain_opts(); opts.page_limit = 5; // Collect ALL matches across all pages and verify no duplicates and full coverage. let mut all_line_texts: Vec = Vec::new(); let mut pages = 0; let max_pages = 20; // safety limit loop { let parsed = parse_grep_query("unique_token"); let result = picker.grep(&parsed, &opts); for m in &result.matches { let text = m.line_content.trim().to_string(); assert!( !all_line_texts.contains(&text), "duplicate match across pages: '{}'", text ); all_line_texts.push(text); } pages += 1; assert!(pages <= max_pages, "pagination did not terminate"); if result.next_file_offset == 0 { break; } // Offset must strictly advance assert!( result.next_file_offset > opts.file_offset, "next_file_offset ({}) did not advance past current ({})", result.next_file_offset, opts.file_offset ); opts.file_offset = result.next_file_offset; } assert_eq!( all_line_texts.len(), 20, "pagination should find all 20 matches across all pages, got {}", all_line_texts.len() ); assert!( pages > 1, "should require multiple pages with page_limit=5 and 20 files" ); } #[test] fn plain_text_line_numbers_are_correct() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "line one\nline two\nline three\nline four\n")], ); let parsed = parse_grep_query("line"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 4); assert_eq!(result.matches[0].line_number, 1); assert_eq!(result.matches[1].line_number, 2); assert_eq!(result.matches[2].line_number, 3); assert_eq!(result.matches[3].line_number, 4); } #[test] fn plain_text_max_file_size_filter() { let tmp = TempDir::new().unwrap(); // Create a file larger than 100 bytes let big_content = "a".repeat(200) + "\nmatch_me\n"; fs::write(tmp.path().join("big.txt"), &big_content).unwrap(); let picker = create_picker(tmp.path(), &[("big.txt", &big_content)]); let mut opts = plain_opts(); opts.max_file_size = 100; // Only allow files up to 100 bytes let parsed = parse_grep_query("match_me"); let result = picker.grep(&parsed, &opts); assert_eq!(result.matches.len(), 0, "large file should be filtered out"); assert_eq!(result.filtered_file_count, 0); } // ── Regex mode tests ─────────────────────────────────────────────────── #[test] fn regex_basic_pattern() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "foo123\nbar456\nbaz789\nfoo_bar\n")], ); let parsed = parse_grep_query("foo\\d+"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 1); assert!(result.matches[0].line_content.contains("foo123")); } #[test] fn regex_capture_group_matching() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "foobar\nfoobaz\nfoo123\n")]); // Use a capturing group (not lookahead, which regex crate doesn't support) let parsed = parse_grep_query("foo(bar|baz)"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 2); let contents: Vec<&str> = result .matches .iter() .map(|m| m.line_content.as_str()) .collect(); assert!(contents.contains(&"foobar")); assert!(contents.contains(&"foobaz")); } #[test] fn regex_dot_matches_any_char() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "v1.0\nv1x0\nv1-0\nv100\nv2.0\n")]); // In regex mode, . matches any character, so v1.0 matches v1.0, v1x0, v1-0, and v100 let parsed = parse_grep_query("v1.0"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!( result.matches.len(), 4, "regex dot should match v1.0, v1x0, v1-0, and v100 (dot matches any char)" ); } #[test] fn regex_alternation() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "apple\nbanana\ncherry\napricot\n")]); let parsed = parse_grep_query("apple|cherry"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 2); let lines: Vec = result.matches.iter().map(|m| m.line_number).collect(); assert!(lines.contains(&1)); assert!(lines.contains(&3)); } #[test] fn regex_character_class() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "cat\ncut\ncot\ncit\ncxt\n")]); let parsed = parse_grep_query("c[aou]t"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 3); let contents: Vec<&str> = result .matches .iter() .map(|m| m.line_content.as_str()) .collect(); assert!(contents.contains(&"cat")); assert!(contents.contains(&"cut")); assert!(contents.contains(&"cot")); } #[test] fn regex_quantifiers() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "fo\nfoo\nfooo\nfoooo\nbar\n")]); let parsed = parse_grep_query("fo{2,3}"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 3, "should match foo, fooo, foooo"); } #[test] fn regex_anchors() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "start of line\nmiddle start end\nend of line\n")], ); let parsed = parse_grep_query("^start"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 1); } #[test] fn regex_anchors_multiword() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "test.c", "int ff_function(void);\nstatic int ff_other(void);\nint main(void);\nint ff_another(void);\n", )], ); // ^int ff_ should match lines starting with "int ff_" let parsed = parse_grep_query("^int ff_"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!( result.matches.len(), 2, "should match 2 lines starting with 'int ff_'" ); assert!(result.matches[0].line_content.contains("ff_function")); assert!(result.matches[1].line_content.contains("ff_another")); } #[test] fn regex_highlight_offsets_variable_length() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "aab aaab aaaab\n")]); let parsed = parse_grep_query("a+b"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!(result.matches.len(), 1); let m = &result.matches[0]; // Regex "a+b" matches "aab" (3 bytes), "aaab" (4 bytes), "aaaab" (5 bytes) assert_eq!(m.match_byte_offsets.len(), 3); // Verify the match spans have different lengths (variable-length regex) assert_eq!(m.match_byte_offsets[0], (0, 3)); // "aab" assert_eq!(m.match_byte_offsets[1], (4, 8)); // "aaab" assert_eq!(m.match_byte_offsets[2], (9, 14)); // "aaaab" } #[test] fn regex_invalid_pattern_falls_back_to_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "call name(arg)\nother line\n")]); // Invalid regex: unmatched group — should fall back to literal search let parsed = parse_grep_query("name("); let result = picker.grep(&parsed, ®ex_opts()); // Fallback to literal: finds "name(" in "call name(arg)" assert_eq!( result.matches.len(), 1, "invalid regex should fall back to literal and find the match" ); assert!( result.regex_fallback_error.is_some(), "should report the regex compilation error" ); assert!(result.matches[0].line_content.contains("name(")); // A pattern that doesn't exist anywhere — still falls back but finds nothing let parsed2 = parse_grep_query("zzz("); let result2 = picker.grep(&parsed2, ®ex_opts()); assert_eq!(result2.matches.len(), 0); assert!(result2.regex_fallback_error.is_some()); } #[test] fn regex_smart_case() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "Foo bar\nfoo bar\nFOO BAR\n")]); // Lowercase query → case-insensitive let parsed_lower = parse_grep_query("foo"); let result_lower = picker.grep(&parsed_lower, ®ex_opts()); assert_eq!(result_lower.matches.len(), 3); // Query with uppercase → case-sensitive let parsed_upper = parse_grep_query("Foo"); let result_upper = picker.grep(&parsed_upper, ®ex_opts()); assert_eq!(result_upper.matches.len(), 1); } #[test] fn regex_across_multiple_files() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("lib.rs", "fn main() {}\nfn helper() {}\nstruct Foo;\n"), ( "test.rs", "fn test_one() {}\nfn test_two() {}\nmod tests;\n", ), ("readme.md", "# Title\nSome text\n"), ], ); let parsed = parse_grep_query("fn \\w+\\(\\)"); let result = picker.grep(&parsed, ®ex_opts()); // Should match: fn main(), fn helper(), fn test_one(), fn test_two() assert_eq!(result.matches.len(), 4); assert_eq!(result.files.len(), 2, "matches in 2 .rs files, not readme"); } // ── Mode comparison tests ────────────────────────────────────────────── #[test] fn plain_text_and_regex_agree_on_simple_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "hello world\ngoodbye world\nhello again\n")], ); let parsed = parse_grep_query("hello"); let plain_result = picker.grep(&parsed, &plain_opts()); let regex_result = picker.grep(&parsed, ®ex_opts()); assert_eq!(plain_result.matches.len(), regex_result.matches.len()); for (p, r) in plain_result.matches.iter().zip(regex_result.matches.iter()) { assert_eq!(p.line_number, r.line_number); assert_eq!(p.line_content, r.line_content); } } #[test] fn plain_text_escapes_what_regex_does_not() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "price is $100\nprice is 100\nprice is $200\n")], ); // "$100" — in plain text, $ is literal; in regex, $ is anchor let parsed_plain = parse_grep_query("$100"); let plain_result = picker.grep(&parsed_plain, &plain_opts()); let parsed_regex = parse_grep_query("\\$100"); let regex_result = picker.grep(&parsed_regex, ®ex_opts()); // Plain text should find "$100" literally assert_eq!(plain_result.matches.len(), 1); assert!(plain_result.matches[0].line_content.contains("$100")); // Regex with escaped $ should also find "$100" assert_eq!(regex_result.matches.len(), 1); } // ── Constraint integration tests ─────────────────────────────────────── #[test] fn grep_with_extension_constraint() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("a.rs", "use std::io;\nfn main() {}\n"), ("b.txt", "use std::io;\nsome text\n"), ("c.rs", "use std::fs;\n"), ], ); let parsed = parse_grep_query("use std *.rs"); let result = picker.grep(&parsed, &plain_opts()); // Should only search .rs files for file in &result.files { assert!( file.relative_path(&picker).ends_with(".rs"), "should only match .rs files, got: {}", file.relative_path(&picker) ); } assert!( result.matches.len() >= 2, "should find matches in .rs files" ); } // ── Bracket / glob character tests ───────────────────────────────────── #[test] fn plain_text_bracket_is_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "code.rs", "let x = arr[0];\nlet y = arr[1];\nlet z = something;\n", )], ); let parsed = parse_grep_query("arr[0]"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 1, "brackets should be literal in plain text mode" ); assert_eq!(result.matches[0].line_number, 1); } // ── Backslash escape tests ───────────────────────────────────────────── #[test] fn grep_backslash_escapes_extension_filter() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("a.rs", "contains *.rs pattern\n"), ("b.txt", "also has *.rs here\n"), ], ); // Without escape: "*.rs" is an extension filter, so only .rs files are searched let parsed = parse_grep_query("pattern *.rs"); let result_filter = picker.grep(&parsed, &plain_opts()); assert_eq!( result_filter.files.len(), 1, "*.rs should filter to .rs files" ); // With escape: "\*.rs" is literal text, both files are searched let parsed_escaped = parse_grep_query("\\*.rs"); let result_literal = picker.grep(&parsed_escaped, &plain_opts()); assert_eq!( result_literal.matches.len(), 2, "\\*.rs should search for literal *.rs in all files" ); } #[test] fn grep_backslash_escapes_path_segment() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("src/main.rs", "search for /src/ path\n"), ("lib/utils.rs", "also /src/ mentioned\n"), ], ); // With escape: "\\/src/" is literal text, not a path constraint let parsed = parse_grep_query("\\/src/"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 2, "\\/src/ should search for literal /src/ in all files" ); } #[test] fn grep_backslash_escapes_negation() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "the !test macro\nother stuff\n")]); // With escape: "\\!test" is literal text "!test" let parsed = parse_grep_query("\\!test"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); assert!(result.matches[0].line_content.contains("!test")); } #[test] fn grep_with_path_constraint() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("src/lib.rs", "target_text\n"), ("tests/test.rs", "target_text\n"), ("src/main.rs", "other content\n"), ], ); let parsed = parse_grep_query("target_text /src/"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); let rel = result.files[0].relative_path(&picker); assert!(rel.starts_with("src/") || rel.starts_with("src\\")); } // ── Negated constraint tests ─────────────────────────────────────────── #[test] fn grep_with_negated_extension_constraint() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("src/lib.rs", "target_text\n"), ("src/app.ts", "target_text\n"), ("src/main.rs", "target_text\n"), ], ); let query = "target_text !*.rs"; let parsed = parse_grep_query(query); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 1, "should only find matches in non-.rs files, got {} matches", result.matches.len() ); assert!( result.files[0].relative_path(&picker).ends_with(".ts"), "should only match .ts file, got: {}", result.files[0].relative_path(&picker) ); } #[test] fn grep_with_negated_path_constraint() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("src/lib.rs", "target_text\n"), ("tests/test.rs", "target_text\n"), ("src/main.rs", "other content\n"), ], ); let query = "target_text !/src/"; let parsed = parse_grep_query(query); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 1, "should only find matches outside src/, got {} matches", result.matches.len() ); assert!( result.files[0].relative_path(&picker).starts_with("tests/") || result.files[0] .relative_path(&picker) .starts_with("tests\\"), "should only match tests/ file, got: {}", result.files[0].relative_path(&picker) ); } #[test] fn grep_with_negated_text_constraint() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("src/lib.rs", "target_text\n"), ("tests/helper.rs", "target_text\n"), ("docs/readme.md", "target_text\n"), ], ); let query = "target_text !test"; let parsed = parse_grep_query(query); let result = picker.grep(&parsed, &plain_opts()); // "tests/helper.rs" contains "test" in path, should be excluded assert_eq!( result.matches.len(), 2, "should find matches in files without 'test' in path, got {} matches", result.matches.len() ); for file in &result.files { assert!( !file.relative_path(&picker).contains("test"), "should not match files with 'test' in path, got: {}", file.relative_path(&picker) ); } } // ── Edge case tests ──────────────────────────────────────────────────── #[test] fn grep_empty_file_is_skipped() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("empty.txt", ""), ("text.txt", "findme\n")]); let parsed = parse_grep_query("findme"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); } #[test] fn grep_single_line_no_trailing_newline() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "no newline at end")]); let parsed = parse_grep_query("no newline"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 1); } #[test] fn grep_unicode_content() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("utf8.txt", "日本語テスト\nrégulière\nñoño\n")], ); let parsed = parse_grep_query("régulière"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); assert_eq!(result.matches[0].line_number, 2); let parsed2 = parse_grep_query("ñoño"); let result2 = picker.grep(&parsed2, &plain_opts()); assert_eq!(result2.matches.len(), 1); assert_eq!(result2.matches[0].line_number, 3); } #[test] fn grep_long_line_is_truncated() { let tmp = TempDir::new().unwrap(); let long_line = format!("{}NEEDLE{}", "x".repeat(1000), "y".repeat(1000)); fs::write(tmp.path().join("long.txt"), &long_line).unwrap(); let picker = create_picker(tmp.path(), &[("long.txt", &long_line)]); let parsed = parse_grep_query("NEEDLE"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); // The line_content should be truncated to MAX_LINE_DISPLAY_LEN (512) assert!( result.matches[0].line_content.len() <= 512, "line should be truncated: len={}", result.matches[0].line_content.len() ); } #[test] fn regex_word_boundary() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "foo\nfoobar\nbarfoo\nfoo_baz\n")]); let parsed = parse_grep_query("\\bfoo\\b"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!( result.matches.len(), 1, "only exact word 'foo' should match" ); assert_eq!(result.matches[0].line_number, 1); } #[test] fn plain_text_question_mark_is_literal() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "a.txt", "what is this?\nhow does it work?\nno question here\nwhat?\n", )], ); let parsed = parse_grep_query("?"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 3, "question mark should be literal in plain text mode" ); } #[test] fn plain_text_query_with_question_mark_in_word() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "code.rs", "let x = foo?;\nlet y = bar.baz();\nfoo?.unwrap()\n", )], ); let parsed = parse_grep_query("foo?"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 2, "should find 'foo?' literally in both lines" ); } #[test] fn regex_question_mark_is_quantifier() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "color\ncolour\ncolouur\n")]); // In regex mode, ? means "zero or one of preceding" let parsed = parse_grep_query("colou?r"); let result = picker.grep(&parsed, ®ex_opts()); assert_eq!( result.matches.len(), 2, "regex ? should match 'color' and 'colour' but not 'colouur'" ); } // ── Fuzzy mode tests ─────────────────────────────────────────────────── #[test] fn fuzzy_finds_exact_substring() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("a.txt", "hello world\ngoodbye world\nhello again\n")], ); let parsed = parse_grep_query("hello"); let result = picker.grep(&parsed, &fuzzy_opts()); assert_eq!( result.matches.len(), 2, "fuzzy should find 'hello' in both lines" ); assert!(result.matches[0].line_content.contains("hello")); assert!(result.matches[1].line_content.contains("hello")); } #[test] fn fuzzy_finds_scattered_characters() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "code.rs", "fn mutex_lock() {}\nfn main() {}\nfn mutex_unlock() {}\n", )], ); // "mutex" should fuzzy match "mutex_lock" (contiguous prefix) let parsed = parse_grep_query("mutex"); let result = picker.grep(&parsed, &fuzzy_opts()); assert!( !result.matches.is_empty(), "fuzzy should find 'mutex' in 'mutex_lock'" ); assert!(result.matches[0].line_content.contains("mutex_lock")); } #[test] fn fuzzy_highlight_offsets_correct() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "hello world\n")]); let parsed = parse_grep_query("hell"); let result = picker.grep(&parsed, &fuzzy_opts()); assert_eq!(result.matches.len(), 1); let m = &result.matches[0]; // "hell" should match 'h'(0), 'e'(1), 'l'(2), 'l'(3) in "hello" // These should be converted to byte offsets assert!( !m.match_byte_offsets.is_empty(), "should have highlight offsets" ); } #[test] fn fuzzy_unicode_char_indices() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("utf8.txt", "日本語テスト\nrégulière\nñoño\n")], ); // Use "guli" which is a contiguous ASCII substring within "régulière" // (the chars g-u-l-i appear contiguously between the two accented chars) let parsed = parse_grep_query("guli"); let result = picker.grep(&parsed, &fuzzy_opts()); // Should fuzzy match "régulière" (with multi-byte é and è) // This tests that character-to-byte offset conversion works with UTF-8 assert!(!result.matches.is_empty()); assert!(result.matches[0].line_content.contains("régulière")); } #[test] fn fuzzy_empty_query_returns_empty() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("a.txt", "some content\n")]); let parsed = parse_grep_query(""); let result = picker.grep(&parsed, &fuzzy_opts()); // Empty query returns git-modified files, not fuzzy matches assert_eq!(result.matches.len(), 0); } #[test] fn fuzzy_with_extension_constraint() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[ ("a.rs", "use std::io;\nfn main() {}\n"), ("b.txt", "use std::io;\nsome text\n"), ("c.rs", "use std::fs;\n"), ], ); let parsed = parse_grep_query("use std *.rs"); let result = picker.grep(&parsed, &fuzzy_opts()); // Should only search .rs files for file in &result.files { assert!( file.relative_path(&picker).ends_with(".rs"), "should only match .rs files, got: {}", file.relative_path(&picker) ); } } #[test] fn fuzzy_respects_page_limit() { let tmp = TempDir::new().unwrap(); let mut content = String::new(); for i in 0..100 { content.push_str(&format!("line {} target\n", i)); } fs::write(tmp.path().join("big.txt"), &content).unwrap(); let picker = create_picker(tmp.path(), &[("big.txt", &content)]); let mut opts = fuzzy_opts(); opts.page_limit = 10; opts.max_matches_per_file = 50; let parsed = parse_grep_query("target"); let result = picker.grep(&parsed, &opts); // page_limit is a soft minimum: we always finish the current file, so we // get at least page_limit matches (no data loss) and at most // max_matches_per_file (200) from a single file. assert!( result.matches.len() >= opts.page_limit, "should return at least page_limit matches: got {}", result.matches.len() ); assert!( result.matches.len() <= opts.max_matches_per_file, "should never exceed max_matches_per_file: got {}", result.matches.len() ); assert_eq!( result.matches.len(), opts.max_matches_per_file, "all limit of lines must be returned" ); } #[test] fn fuzzy_respects_max_matches_per_file() { let tmp = TempDir::new().unwrap(); let mut content = String::new(); for i in 0..50 { content.push_str(&format!("line {} match_target\n", i)); } fs::write(tmp.path().join("many.txt"), &content).unwrap(); let picker = create_picker(tmp.path(), &[("many.txt", &content)]); let mut opts = fuzzy_opts(); opts.max_matches_per_file = 5; let parsed = parse_grep_query("match"); let result = picker.grep(&parsed, &opts); assert_eq!( result.matches.len(), 5, "should cap at max_matches_per_file" ); } #[test] fn fuzzy_filters_low_quality_matches() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "code.rs", "fn mutex_lock() {}\nfn xyz() {}\nfn abc_def_ghi() {}\nfn abcdefghij() {}\n", )], ); // Search for "abc" - should match "abc_def_ghi" and "abcdefghij" with high scores, // but NOT "xyz" (no relation) or "mutex_lock" (only weak letter overlap) let parsed = parse_grep_query("abc"); let result = picker.grep(&parsed, &fuzzy_opts()); // Should only get high-quality matches assert!( result.matches.len() <= 2, "should filter out low-quality fuzzy matches, got {} matches", result.matches.len() ); // All matches should contain reasonable character overlap for m in &result.matches { assert!( m.line_content.contains("abc") || m.line_content.contains("abc_"), "match '{}' should be high-quality", m.line_content ); } } #[test] fn fuzzy_exact_match_always_passes() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[("test.txt", "exact match line\nno match here\n")], ); // Exact matches should always pass regardless of score threshold let parsed = parse_grep_query("exact"); let result = picker.grep(&parsed, &fuzzy_opts()); assert_eq!( result.matches.len(), 1, "exact match should always pass score threshold" ); assert!(result.matches[0].line_content.contains("exact")); } #[test] fn fuzzy_score_is_captured() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("test.txt", "hello world\ngoodbye world\n")]); let parsed = parse_grep_query("hello"); let result = picker.grep(&parsed, &fuzzy_opts()); assert_eq!(result.matches.len(), 1); let m = &result.matches[0]; // Fuzzy score should be set (Some) for fuzzy mode matches assert!( m.fuzzy_score.is_some(), "fuzzy_score should be set in fuzzy grep mode" ); assert!( m.fuzzy_score.unwrap() > 0, "fuzzy_score should be positive for a good match" ); } #[test] fn fuzzy_score_is_none_in_plain_mode() { let tmp = TempDir::new().unwrap(); let picker = create_picker(tmp.path(), &[("test.txt", "hello world\n")]); let parsed = parse_grep_query("hello"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!(result.matches.len(), 1); let m = &result.matches[0]; // fuzzy_score should be None for plain text mode assert!( m.fuzzy_score.is_none(), "fuzzy_score should be None in plain text mode" ); } /// Regression: memmem prefilter rejected files where content casing differed /// from the query, even under smart_case. E.g. "vfio-kvm" failed to find /// "VFIO-KVM" because the lowercased finder did a case-sensitive scan. #[test] fn plain_text_smart_case_finds_uppercase_content_with_lowercase_query() { let tmp = TempDir::new().unwrap(); let picker = create_picker( tmp.path(), &[( "driver.c", "// VFIO-KVM integration\nstatic int init(void) {}\n", )], ); let parsed = parse_grep_query("vfio-kvm"); let result = picker.grep(&parsed, &plain_opts()); assert_eq!( result.matches.len(), 1, "lowercase query should case-insensitively match 'VFIO-KVM'" ); }