Skip to main content

ck_engine/
lib.rs

1use anyhow::Result;
2use ck_core::{CkError, IncludePattern, SearchMode, SearchOptions, SearchResult, Span};
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use rayon::prelude::*;
5use regex::{Regex, RegexBuilder};
6use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf as StdPathBuf;
9use std::path::{Path, PathBuf};
10use tantivy::collector::TopDocs;
11use tantivy::query::{Query, QueryParser};
12use tantivy::schema::{STORED, Schema, TEXT, Value};
13use tantivy::{Index, ReloadPolicy, TantivyDocument, doc};
14use walkdir::WalkDir;
15
16mod semantic_v3;
17pub use semantic_v3::{semantic_search_v3, semantic_search_v3_with_progress};
18
19pub type SearchProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
20pub type IndexingProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
21pub type DetailedIndexingProgressCallback = Box<dyn Fn(ck_index::EmbeddingProgress) + Send + Sync>;
22
23/// Resolve the actual file path to read content from
24/// For PDFs: returns cache path and validates it exists
25/// For regular files: returns original path
26fn resolve_content_path(file_path: &Path, repo_root: &Path) -> Result<PathBuf> {
27    if ck_core::pdf::is_pdf_file(file_path) {
28        // PDFs: Read from cached extracted text
29        let cache_path = ck_core::pdf::get_content_cache_path(repo_root, file_path);
30        if !cache_path.exists() {
31            return Err(anyhow::anyhow!(
32                "PDF not preprocessed. Run 'ck --index' first."
33            ));
34        }
35        Ok(cache_path)
36    } else {
37        // Regular files: Read from original source
38        Ok(file_path.to_path_buf())
39    }
40}
41
42/// Read content from file for search result extraction
43/// Regular files: read directly from source
44/// PDFs: read from preprocessed cache
45fn read_file_content(file_path: &Path, repo_root: &Path) -> Result<String> {
46    let content_path = resolve_content_path(file_path, repo_root)?;
47    Ok(fs::read_to_string(content_path)?)
48}
49
50/// Extract content from a file using a span (streaming version)
51async fn extract_content_from_span(file_path: &Path, span: &ck_core::Span) -> Result<String> {
52    // Find repo root to locate cache
53    let repo_root = find_nearest_index_root(file_path)
54        .unwrap_or_else(|| file_path.parent().unwrap_or(file_path).to_path_buf());
55
56    // Use centralized path resolution
57    let content_path = resolve_content_path(file_path, &repo_root)?;
58
59    // Stream only the needed lines
60    extract_lines_from_file(&content_path, span.line_start, span.line_end)
61}
62
63/// Stream-read specific lines from a file without loading the entire content
64fn extract_lines_from_file(file_path: &Path, line_start: usize, line_end: usize) -> Result<String> {
65    use std::io::{BufRead, BufReader};
66
67    if line_start == 0 {
68        return Ok(String::new());
69    }
70
71    let file = fs::File::open(file_path)?;
72    let reader = BufReader::new(file);
73    let mut result = Vec::new();
74
75    // Convert to 0-based indexing
76    let start_idx = line_start.saturating_sub(1);
77    let end_idx = line_end.saturating_sub(1);
78
79    for (current_line, line_result) in reader.lines().enumerate() {
80        if current_line > end_idx {
81            break; // Stop reading once we've passed the needed lines
82        }
83
84        let line = line_result?;
85
86        if current_line >= start_idx {
87            result.push(line);
88        }
89    }
90
91    // Handle case where requested lines exceed file length
92    if result.is_empty() && line_start > 0 {
93        return Ok(String::new());
94    }
95
96    Ok(result.join("\n"))
97}
98
99/// Split content into lines while preserving the exact number of trailing newline bytes per line.
100/// Handles Unix (\n), Windows (\r\n) and old Mac (\r) line endings.
101fn split_lines_with_endings(content: &str) -> (Vec<String>, Vec<usize>) {
102    let mut lines = Vec::new();
103    let mut endings = Vec::new();
104
105    let bytes = content.as_bytes();
106    let mut start = 0usize;
107    let mut i = 0usize;
108
109    while i < bytes.len() {
110        match bytes[i] {
111            b'\n' => {
112                lines.push(content[start..i].to_string());
113                endings.push(1);
114                i += 1;
115                start = i;
116            }
117            b'\r' => {
118                lines.push(content[start..i].to_string());
119                if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
120                    endings.push(2);
121                    i += 2;
122                } else {
123                    endings.push(1);
124                    i += 1;
125                }
126                start = i;
127            }
128            _ => {
129                i += 1;
130            }
131        }
132    }
133
134    if start < bytes.len() {
135        lines.push(content[start..].to_string());
136        endings.push(0);
137    }
138
139    (lines, endings)
140}
141
142fn canonicalize_for_matching(path: &Path) -> PathBuf {
143    if let Ok(canonical) = path.canonicalize() {
144        return canonical;
145    }
146
147    if path.is_absolute() {
148        path.to_path_buf()
149    } else {
150        std::env::current_dir()
151            .map(|cwd| cwd.join(path))
152            .unwrap_or_else(|_| path.to_path_buf())
153    }
154}
155
156fn path_matches_include(path: &Path, include_patterns: &[IncludePattern]) -> bool {
157    if include_patterns.is_empty() {
158        return true;
159    }
160
161    let candidate = canonicalize_for_matching(path);
162    include_patterns.iter().any(|pattern| {
163        if pattern.is_dir {
164            candidate.starts_with(&pattern.path)
165        } else {
166            candidate == pattern.path
167        }
168    })
169}
170
171fn filter_files_by_include(
172    files: Vec<PathBuf>,
173    include_patterns: &[IncludePattern],
174) -> Vec<PathBuf> {
175    if include_patterns.is_empty() {
176        return files;
177    }
178
179    files
180        .into_iter()
181        .filter(|path| path_matches_include(path, include_patterns))
182        .collect()
183}
184
185fn find_nearest_index_root(path: &Path) -> Option<StdPathBuf> {
186    let mut current = if path.is_file() {
187        path.parent().unwrap_or(path)
188    } else {
189        path
190    };
191    loop {
192        if ck_core::index_exists(current) {
193            return Some(current.to_path_buf());
194        }
195        match current.parent() {
196            Some(parent) => current = parent,
197            None => return None,
198        }
199    }
200}
201
202#[derive(Clone, Debug)]
203pub struct ResolvedModel {
204    pub alias: String,
205    pub config: ck_models::ModelConfig,
206}
207
208impl ResolvedModel {
209    pub fn canonical_name(&self) -> &str {
210        self.config.name.as_str()
211    }
212
213    pub fn dimensions(&self) -> usize {
214        self.config.dimensions
215    }
216}
217
218fn legacy_model_config(name: &str, dimensions: usize) -> ck_models::ModelConfig {
219    ck_models::ModelConfig {
220        name: name.to_string(),
221        provider: "fastembed".to_string(),
222        dimensions,
223        max_tokens: 8192,
224        description: "Legacy ck embedding model preserved for backwards compatibility".to_string(),
225    }
226}
227
228pub(crate) fn resolve_model_from_root(
229    index_root: &Path,
230    cli_model: Option<&str>,
231) -> Result<ResolvedModel> {
232    use ck_models::ModelRegistry;
233
234    let registry = ModelRegistry::default();
235    let index_dir = ck_core::index_dir(index_root);
236    let manifest_path = index_dir.join("manifest.json");
237
238    if manifest_path.exists() {
239        let data = std::fs::read(&manifest_path)?;
240        let manifest: ck_index::IndexManifest = serde_json::from_slice(&data)?;
241
242        if let Some(existing_model) = manifest.embedding_model {
243            let dims_hint = manifest.embedding_dimensions.unwrap_or(384);
244            let resolved_existing = match registry.resolve(Some(existing_model.as_str())) {
245                Ok((alias, config)) => ResolvedModel { alias, config },
246                Err(_) => ResolvedModel {
247                    alias: existing_model.clone(),
248                    config: legacy_model_config(&existing_model, dims_hint),
249                },
250            };
251
252            if let Some(requested) = cli_model {
253                let (requested_alias, requested_config) = registry
254                    .resolve(Some(requested))
255                    .map_err(|e| CkError::Embedding(e.to_string()))?;
256
257                if requested_config.name != resolved_existing.config.name {
258                    let suggested_alias = resolved_existing.alias.clone();
259                    return Err(CkError::Embedding(format!(
260                        "Index was built with embedding model '{}' (alias '{}'), but '--model {}' was requested. To switch models run `ck --clean .` then `ck --index --model {}`. To keep using this index rerun your command with '--model {}'.",
261                        resolved_existing.config.name,
262                        suggested_alias,
263                        requested,
264                        requested,
265                        suggested_alias
266                    ))
267                    .into());
268                }
269
270                return Ok(ResolvedModel {
271                    alias: requested_alias,
272                    config: requested_config,
273                });
274            }
275
276            return Ok(resolved_existing);
277        }
278    }
279
280    let (alias, config) = registry
281        .resolve(cli_model)
282        .map_err(|e| CkError::Embedding(e.to_string()))?;
283
284    Ok(ResolvedModel { alias, config })
285}
286
287pub fn resolve_model_for_path(path: &Path, cli_model: Option<&str>) -> Result<ResolvedModel> {
288    let index_root = find_nearest_index_root(path).unwrap_or_else(|| {
289        if path.is_file() {
290            path.parent().unwrap_or(path).to_path_buf()
291        } else {
292            path.to_path_buf()
293        }
294    });
295    resolve_model_from_root(&index_root, cli_model)
296}
297
298pub async fn search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
299    let results = search_enhanced(options).await?;
300    Ok(results.matches)
301}
302
303pub async fn search_with_progress(
304    options: &SearchOptions,
305    progress_callback: Option<SearchProgressCallback>,
306) -> Result<Vec<SearchResult>> {
307    let results = search_enhanced_with_progress(options, progress_callback).await?;
308    Ok(results.matches)
309}
310
311/// Enhanced search that includes near-miss information for threshold queries
312pub async fn search_enhanced(options: &SearchOptions) -> Result<ck_core::SearchResults> {
313    search_enhanced_with_progress(options, None).await
314}
315
316/// Enhanced search with progress callback that includes near-miss information
317pub async fn search_enhanced_with_progress(
318    options: &SearchOptions,
319    progress_callback: Option<SearchProgressCallback>,
320) -> Result<ck_core::SearchResults> {
321    search_enhanced_with_indexing_progress(options, progress_callback, None, None).await
322}
323
324/// Enhanced search with both search and indexing progress callbacks
325/// Summary of index maintenance performed automatically before a search.
326#[derive(Debug, Clone, Default)]
327pub struct IndexUpdate {
328    pub files_indexed: usize,
329    pub orphaned_files_removed: usize,
330    /// Wall-clock time spent checking and updating the index, in milliseconds.
331    /// This covers the staleness check even when no files needed work.
332    pub duration_ms: u64,
333}
334
335impl IndexUpdate {
336    /// True when the index actually changed (files re-indexed or removed),
337    /// as opposed to a no-op staleness check.
338    pub fn did_work(&self) -> bool {
339        self.files_indexed > 0 || self.orphaned_files_removed > 0
340    }
341}
342
343/// Search results plus what auto-indexing did to produce them, so callers can
344/// report search latency separately from index-build latency.
345#[derive(Debug)]
346pub struct SearchOutcome {
347    pub results: ck_core::SearchResults,
348    /// `None` for regex mode (which never touches the index).
349    pub index_update: Option<IndexUpdate>,
350}
351
352pub async fn search_enhanced_with_indexing_progress(
353    options: &SearchOptions,
354    progress_callback: Option<SearchProgressCallback>,
355    indexing_progress_callback: Option<IndexingProgressCallback>,
356    detailed_indexing_progress_callback: Option<DetailedIndexingProgressCallback>,
357) -> Result<ck_core::SearchResults> {
358    let outcome = search_enhanced_with_outcome(
359        options,
360        progress_callback,
361        indexing_progress_callback,
362        detailed_indexing_progress_callback,
363    )
364    .await?;
365    Ok(outcome.results)
366}
367
368pub async fn search_enhanced_with_outcome(
369    options: &SearchOptions,
370    progress_callback: Option<SearchProgressCallback>,
371    indexing_progress_callback: Option<IndexingProgressCallback>,
372    detailed_indexing_progress_callback: Option<DetailedIndexingProgressCallback>,
373) -> Result<SearchOutcome> {
374    // Validate that the search path exists
375    if !options.path.exists() {
376        return Err(ck_core::CkError::Search(format!(
377            "Path does not exist: {}",
378            options.path.display()
379        ))
380        .into());
381    }
382
383    // Auto-update index if needed (unless it's regex-only mode)
384    let mut index_update = None;
385    if !matches!(options.mode, SearchMode::Regex) {
386        let need_embeddings = matches!(options.mode, SearchMode::Semantic | SearchMode::Hybrid);
387        let file_options = ck_core::FileCollectionOptions::from(options);
388        let started = std::time::Instant::now();
389        let update_stats = ensure_index_updated_with_progress(
390            &options.path,
391            options.reindex,
392            need_embeddings,
393            indexing_progress_callback,
394            detailed_indexing_progress_callback,
395            &file_options,
396            options.embedding_model.as_deref(),
397        )
398        .await?;
399        index_update = Some(IndexUpdate {
400            files_indexed: update_stats
401                .as_ref()
402                .map(|s| s.files_indexed)
403                .unwrap_or_default(),
404            orphaned_files_removed: update_stats
405                .as_ref()
406                .map(|s| s.orphaned_files_removed)
407                .unwrap_or_default(),
408            duration_ms: started.elapsed().as_millis() as u64,
409        });
410    }
411
412    let search_results = match options.mode {
413        SearchMode::Regex => {
414            let matches = regex_search(options)?;
415            ck_core::SearchResults {
416                matches,
417                closest_below_threshold: None,
418            }
419        }
420        SearchMode::Lexical => {
421            let matches = lexical_search(options).await?;
422            ck_core::SearchResults {
423                matches,
424                closest_below_threshold: None,
425            }
426        }
427        SearchMode::Semantic => {
428            // Use v3 semantic search (reads pre-computed embeddings from sidecars using spans)
429            semantic_search_v3_with_progress(options, progress_callback).await?
430        }
431        SearchMode::Hybrid => {
432            let matches = hybrid_search_with_progress(options, progress_callback).await?;
433            ck_core::SearchResults {
434                matches,
435                closest_below_threshold: None,
436            }
437        }
438    };
439
440    Ok(SearchOutcome {
441        results: search_results,
442        index_update,
443    })
444}
445
446fn regex_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
447    let pattern = if options.fixed_string {
448        regex::escape(&options.query)
449    } else if options.whole_word {
450        format!(r"\b{}\b", regex::escape(&options.query))
451    } else {
452        options.query.clone()
453    };
454
455    let regex = RegexBuilder::new(&pattern)
456        .case_insensitive(options.case_insensitive)
457        .build()
458        .map_err(CkError::Regex)?;
459
460    // Default to recursive for directories (like grep) to maintain compatibility
461    let should_recurse = options.path.is_dir() || options.recursive;
462    let files = if should_recurse {
463        // Use ck_index's collect_files which respects gitignore
464        let file_options = ck_core::FileCollectionOptions {
465            respect_gitignore: options.respect_gitignore,
466            use_ckignore: options.use_ckignore,
467            exclude_patterns: options.exclude_patterns.clone(),
468            show_hidden: options.hidden,
469        };
470        let collected = ck_index::collect_files(&options.path, &file_options)?;
471        filter_files_by_include(collected, &options.include_patterns)
472    } else {
473        // For non-recursive, use the local collect_files
474        let collected = collect_files(&options.path, should_recurse, &options.exclude_patterns)?;
475        filter_files_by_include(collected, &options.include_patterns)
476    };
477
478    let results: Vec<Vec<SearchResult>> = files
479        .par_iter()
480        .filter_map(|file_path| match search_file(&regex, file_path, options) {
481            Ok(matches) => {
482                if matches.is_empty() {
483                    None
484                } else {
485                    Some(matches)
486                }
487            }
488            Err(e) => {
489                tracing::debug!("Error searching {:?}: {}", file_path, e);
490                None
491            }
492        })
493        .collect();
494
495    let mut all_results: Vec<SearchResult> = results.into_iter().flatten().collect();
496    // Deterministic ordering: file path, then line number
497    all_results.sort_by(|a, b| {
498        let path_cmp = a.file.cmp(&b.file);
499        if path_cmp != std::cmp::Ordering::Equal {
500            return path_cmp;
501        }
502        a.span.line_start.cmp(&b.span.line_start)
503    });
504
505    if let Some(top_k) = options.top_k {
506        all_results.truncate(top_k);
507    }
508
509    Ok(all_results)
510}
511
512fn search_file(
513    regex: &Regex,
514    file_path: &Path,
515    options: &SearchOptions,
516) -> Result<Vec<SearchResult>> {
517    // Find repo root to locate cache
518    let repo_root = find_nearest_index_root(file_path)
519        .unwrap_or_else(|| file_path.parent().unwrap_or(file_path).to_path_buf());
520
521    // For full_section mode, we need the entire content for parsing
522    // For context previews, we need all lines for surrounding context
523    // So we'll load content when needed, but optimize for the common case
524    if options.full_section || options.context_lines > 0 {
525        // Load full content when we need section parsing or context
526        let content = read_file_content(file_path, &repo_root)?;
527        let (lines, line_ending_lengths) = split_lines_with_endings(&content);
528
529        // If full_section is enabled, try to parse the file and find code sections
530        let code_sections = if options.full_section {
531            extract_code_sections(file_path, &content)
532        } else {
533            None
534        };
535
536        search_file_in_memory(
537            regex,
538            file_path,
539            options,
540            &lines,
541            &code_sections,
542            &line_ending_lengths,
543        )
544    } else {
545        // Streaming search (simple case)
546        search_file_streaming(regex, file_path, &repo_root, options)
547    }
548}
549
550/// In-memory search for cases requiring context or code sections
551fn search_file_in_memory(
552    regex: &Regex,
553    file_path: &Path,
554    options: &SearchOptions,
555    lines: &[String],
556    code_sections: &Option<Vec<(usize, usize, String)>>,
557    line_ending_lengths: &[usize],
558) -> Result<Vec<SearchResult>> {
559    let mut results = Vec::new();
560    let mut byte_offset = 0;
561
562    for (line_idx, line) in lines.iter().enumerate() {
563        let line_number = line_idx + 1;
564
565        // Special handling for empty pattern - match the entire line once
566        // An empty regex pattern will match at every position, so we need to handle it specially
567        if regex.as_str().is_empty() {
568            // Empty pattern matches the whole line once (grep compatibility)
569            let preview = if options.full_section {
570                // Try to find the containing code section
571                if let Some(sections) = code_sections {
572                    if let Some(section) = find_containing_section(sections, line_idx) {
573                        section.clone()
574                    } else {
575                        // Fall back to context lines if no section found
576                        get_context_preview(lines, line_idx, options)
577                    }
578                } else {
579                    get_context_preview(lines, line_idx, options)
580                }
581            } else {
582                get_context_preview(lines, line_idx, options)
583            };
584
585            results.push(SearchResult {
586                file: file_path.to_path_buf(),
587                span: Span {
588                    byte_start: byte_offset,
589                    byte_end: byte_offset + line.len(),
590                    line_start: line_number,
591                    line_end: line_number,
592                },
593                score: 1.0,
594                preview,
595                lang: ck_core::Language::from_path(file_path),
596                symbol: None,
597                chunk_hash: None,
598                index_epoch: None,
599            });
600        } else {
601            // Find all matches in the line with their positions
602            for mat in regex.find_iter(line) {
603                let preview = if options.full_section {
604                    // Try to find the containing code section
605                    if let Some(sections) = code_sections {
606                        if let Some(section) = find_containing_section(sections, line_idx) {
607                            section.clone()
608                        } else {
609                            // Fall back to context lines if no section found
610                            get_context_preview(lines, line_idx, options)
611                        }
612                    } else {
613                        get_context_preview(lines, line_idx, options)
614                    }
615                } else {
616                    get_context_preview(lines, line_idx, options)
617                };
618
619                results.push(SearchResult {
620                    file: file_path.to_path_buf(),
621                    span: Span {
622                        byte_start: byte_offset + mat.start(),
623                        byte_end: byte_offset + mat.end(),
624                        line_start: line_number,
625                        line_end: line_number,
626                    },
627                    score: 1.0,
628                    preview,
629                    lang: ck_core::Language::from_path(file_path),
630                    symbol: None,
631                    chunk_hash: None,
632                    index_epoch: None,
633                });
634            }
635        }
636
637        // Update byte offset for next line (add line length + actual line ending length)
638        byte_offset += line.len();
639        byte_offset += line_ending_lengths.get(line_idx).copied().unwrap_or(0);
640    }
641
642    Ok(results)
643}
644
645/// Streaming search for simple cases without context or code sections
646fn search_file_streaming(
647    regex: &Regex,
648    file_path: &Path,
649    repo_root: &Path,
650    _options: &SearchOptions,
651) -> Result<Vec<SearchResult>> {
652    use std::io::{BufRead, BufReader};
653
654    let content_path = resolve_content_path(file_path, repo_root)?;
655    let file = std::fs::File::open(&content_path)?;
656    let mut reader = BufReader::new(file);
657
658    let mut results = Vec::new();
659    let mut line = String::new();
660    let mut byte_offset = 0usize;
661    let mut line_number = 1usize;
662
663    loop {
664        line.clear();
665        let bytes_read = reader.read_line(&mut line)?;
666        if bytes_read == 0 {
667            break;
668        }
669
670        // Determine the length of the trailing line ending (if any) and
671        // normalise the line buffer so it no longer contains newline bytes.
672        let mut newline_len = 0usize;
673        if line.ends_with("\r\n") {
674            line.pop(); // remove \n
675            line.pop(); // remove \r
676            newline_len = 2;
677        } else if line.ends_with(['\n', '\r']) {
678            line.pop();
679            newline_len = 1;
680        }
681
682        // Old Mac-style files may use bare carriage returns as separators.
683        // When the trimmed line still contains '\r' characters, treat them as
684        // record separators so the byte offsets remain accurate.
685        let treat_cr_as_newline = line.contains('\r');
686
687        if treat_cr_as_newline {
688            let bytes = line.as_bytes();
689            let mut segment_start = 0usize;
690            while segment_start <= bytes.len() {
691                match bytes[segment_start..].iter().position(|&b| b == b'\r') {
692                    Some(rel_idx) => {
693                        let idx = segment_start + rel_idx;
694                        let segment_bytes = &bytes[segment_start..idx];
695                        let segment_str = std::str::from_utf8(segment_bytes)?;
696                        process_streaming_line(
697                            regex,
698                            file_path,
699                            segment_str,
700                            line_number,
701                            byte_offset,
702                            &mut results,
703                        );
704                        byte_offset += segment_bytes.len() + 1; // account for \r
705                        line_number += 1;
706                        segment_start = idx + 1;
707                    }
708                    None => {
709                        let segment_bytes = &bytes[segment_start..];
710                        let segment_str = std::str::from_utf8(segment_bytes)?;
711                        process_streaming_line(
712                            regex,
713                            file_path,
714                            segment_str,
715                            line_number,
716                            byte_offset,
717                            &mut results,
718                        );
719                        byte_offset += segment_bytes.len();
720                        line_number += 1;
721                        break;
722                    }
723                }
724            }
725            byte_offset += newline_len;
726        } else {
727            let line_str = line.as_str();
728            process_streaming_line(
729                regex,
730                file_path,
731                line_str,
732                line_number,
733                byte_offset,
734                &mut results,
735            );
736            byte_offset += line_str.len() + newline_len;
737            line_number += 1;
738        }
739    }
740
741    Ok(results)
742}
743
744fn process_streaming_line(
745    regex: &Regex,
746    file_path: &Path,
747    line: &str,
748    line_number: usize,
749    byte_offset: usize,
750    results: &mut Vec<SearchResult>,
751) {
752    if regex.as_str().is_empty() {
753        results.push(SearchResult {
754            file: file_path.to_path_buf(),
755            span: Span {
756                byte_start: byte_offset,
757                byte_end: byte_offset + line.len(),
758                line_start: line_number,
759                line_end: line_number,
760            },
761            score: 1.0,
762            preview: line.to_string(),
763            lang: ck_core::Language::from_path(file_path),
764            symbol: None,
765            chunk_hash: None,
766            index_epoch: None,
767        });
768    } else {
769        for mat in regex.find_iter(line) {
770            results.push(SearchResult {
771                file: file_path.to_path_buf(),
772                span: Span {
773                    byte_start: byte_offset + mat.start(),
774                    byte_end: byte_offset + mat.end(),
775                    line_start: line_number,
776                    line_end: line_number,
777                },
778                score: 1.0,
779                preview: line.to_string(),
780                lang: ck_core::Language::from_path(file_path),
781                symbol: None,
782                chunk_hash: None,
783                index_epoch: None,
784            });
785        }
786    }
787}
788
789/// Name of the metadata file (inside `.ck`) recording the corpus fingerprint
790/// the tantivy index was built from, so staleness is detectable.
791const TANTIVY_META_FILE: &str = "tantivy_index.meta";
792
793/// Fingerprint of the file set a tantivy index covers: path, mtime and size
794/// of every corpus file. Any added, removed, or modified file changes the
795/// fingerprint, as does a different exclude-pattern set (it changes the
796/// collected file list).
797fn lexical_corpus_fingerprint(files: &[PathBuf]) -> String {
798    let mut entries: Vec<String> = files
799        .iter()
800        .map(|f| {
801            let (mtime, size) = fs::metadata(f)
802                .map(|m| {
803                    let mtime = m
804                        .modified()
805                        .ok()
806                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
807                        .map(|d| d.as_nanos())
808                        .unwrap_or(0);
809                    (mtime, m.len())
810                })
811                .unwrap_or((0, 0));
812            format!("{}\x00{}\x00{}", f.display(), mtime, size)
813        })
814        .collect();
815    entries.sort_unstable();
816
817    let mut hasher = blake3::Hasher::new();
818    for entry in &entries {
819        hasher.update(entry.as_bytes());
820        hasher.update(b"\n");
821    }
822    hasher.finalize().to_hex().to_string()
823}
824
825/// Split a lexical query into comparison terms the same way tantivy's default
826/// tokenizer does: lowercased and split on non-alphanumeric boundaries.
827fn lexical_query_terms(query: &str) -> Vec<String> {
828    query
829        .split(|c: char| !c.is_alphanumeric())
830        .filter(|term| !term.is_empty())
831        .map(str::to_lowercase)
832        .collect()
833}
834
835/// Refine the span and preview reported for a lexical hit down to the chunk
836/// that best matches the query.
837///
838/// `terms` are the content-field terms of the parsed tantivy query (already
839/// tokenized and lowercased), not the raw query string — so field prefixes,
840/// quotes, and boolean operators, which tantivy strips before matching, don't
841/// leak into span selection.
842///
843/// Retrieval is document-granular, so this runs only after the top-k documents
844/// are selected and never changes which documents match or their scores. The
845/// file is chunked with the same chunker the semantic path uses; the winning
846/// chunk is the one with the most whole-token matches for `terms`, ties broken
847/// toward the earliest chunk. When `terms` is empty, no chunk contains a term,
848/// or the content cannot be chunked, the whole-file span and a first-lines
849/// preview are returned unchanged.
850fn locate_lexical_span(
851    file_path: &Path,
852    content: &str,
853    terms: &[String],
854    full_section: bool,
855) -> (Span, String) {
856    let whole_file = || {
857        let span = Span {
858            byte_start: 0,
859            byte_end: content.len(),
860            line_start: 1,
861            line_end: content.lines().count(),
862        };
863        let preview = if full_section {
864            content.to_string()
865        } else {
866            content.lines().take(3).collect::<Vec<_>>().join("\n")
867        };
868        (span, preview)
869    };
870
871    if terms.is_empty() {
872        return whole_file();
873    }
874
875    // Detect language for tree-sitter parsing the same way the indexer does.
876    let lang = if ck_core::pdf::is_pdf_file(file_path) {
877        Some(ck_core::Language::Pdf)
878    } else {
879        ck_core::Language::from_path(file_path)
880    };
881
882    let chunks = match ck_chunk::chunk_text(content, lang) {
883        Ok(chunks) => chunks,
884        Err(_) => return whole_file(),
885    };
886
887    // Keep the earliest chunk with the highest query-term count: only a strictly
888    // greater count replaces the incumbent, so ties favor the earlier chunk.
889    // Count whole-token matches, not substrings — the chunk is tokenized the
890    // same way the query is, so a short term like "in" doesn't tally hits inside
891    // larger words ("string", "printing") and let a coincidental chunk win.
892    let mut best: Option<(usize, &ck_chunk::Chunk)> = None;
893    for chunk in &chunks {
894        let hits = lexical_query_terms(&chunk.text)
895            .into_iter()
896            .filter(|token| terms.contains(token))
897            .count();
898        if hits == 0 {
899            continue;
900        }
901        if best.is_none_or(|(best_hits, _)| hits > best_hits) {
902            best = Some((hits, chunk));
903        }
904    }
905
906    match best {
907        Some((_, chunk)) => {
908            let preview = if full_section {
909                content.to_string()
910            } else {
911                chunk.text.clone()
912            };
913            (chunk.span.clone(), preview)
914        }
915        None => whole_file(),
916    }
917}
918
919async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
920    // Handle both files and directories and reuse nearest existing .ck index up the tree
921    let index_root = find_nearest_index_root(&options.path).unwrap_or_else(|| {
922        if options.path.is_file() {
923            options.path.parent().unwrap_or(&options.path).to_path_buf()
924        } else {
925            options.path.clone()
926        }
927    });
928
929    let index_dir = ck_core::index_dir(&index_root);
930    if !index_dir.exists() {
931        return Err(CkError::Index("No index found. Run 'ck index' first.".to_string()).into());
932    }
933    // Refuse to serve results from an index dir that a different root claimed
934    // via a CK_INDEX_DIR basename-hash collision. No-op in-tree.
935    ck_core::check_index_root_marker(&index_root)?;
936
937    let tantivy_index_path = index_dir.join("tantivy_index");
938
939    // The tantivy index always covers the whole index root (include patterns
940    // are applied per result at search time below), so corpus membership only
941    // depends on the root and the exclusion rules.
942    //
943    // Collection goes through ck_index::collect_files — the same walker the
944    // regex and semantic paths use — so gitignore/.ckignore semantics match
945    // and exclude patterns apply relative to the walk root. The engine-local
946    // collect_files matched exclude globs against every *absolute* path
947    // component, so a corpus under e.g. /tmp on Linux matched the default
948    // "tmp" exclude and silently produced an empty lexical index.
949    let file_options = ck_core::FileCollectionOptions {
950        respect_gitignore: options.respect_gitignore,
951        use_ckignore: options.use_ckignore,
952        exclude_patterns: options.exclude_patterns.clone(),
953        show_hidden: options.hidden,
954    };
955    let corpus = ck_index::collect_files(&index_root, &file_options)?;
956    let fingerprint = lexical_corpus_fingerprint(&corpus);
957    let meta_path = index_dir.join(TANTIVY_META_FILE);
958    let is_fresh = tantivy_index_path.exists()
959        && fs::read_to_string(&meta_path)
960            .map(|stored| stored.trim() == fingerprint)
961            .unwrap_or(false);
962
963    if !is_fresh {
964        // Serialize with index mutations (and concurrent lexical rebuilds);
965        // re-check freshness after acquiring in case another process just
966        // rebuilt the same corpus.
967        let _lock = ck_index::acquire_index_write_lock(&index_dir)?;
968        let still_stale = !tantivy_index_path.exists()
969            || fs::read_to_string(&meta_path)
970                .map(|stored| stored.trim() != fingerprint)
971                .unwrap_or(true);
972        if still_stale {
973            tracing::info!(
974                "Lexical index stale or missing for {}; rebuilding from {} files",
975                index_root.display(),
976                corpus.len()
977            );
978            build_tantivy_index(&tantivy_index_path, &corpus)?;
979            fs::write(&meta_path, &fingerprint)?;
980        }
981    }
982
983    let mut schema_builder = Schema::builder();
984    let content_field = schema_builder.add_text_field("content", TEXT | STORED);
985    let path_field = schema_builder.add_text_field("path", TEXT | STORED);
986    let _schema = schema_builder.build();
987
988    let index = Index::open_in_dir(&tantivy_index_path)
989        .map_err(|e| CkError::Index(format!("Failed to open tantivy index: {e}")))?;
990
991    let reader = index
992        .reader_builder()
993        .reload_policy(ReloadPolicy::OnCommitWithDelay)
994        .try_into()
995        .map_err(|e| CkError::Index(format!("Failed to create index reader: {e}")))?;
996
997    let searcher = reader.searcher();
998    let query_parser = QueryParser::for_index(&index, vec![content_field]);
999
1000    // Parse leniently so any string is a valid query: syntax tantivy can't
1001    // interpret (unbalanced quotes, stray field colons, bare boolean operators)
1002    // degrades to the terms it can parse instead of erroring. A query that
1003    // already parses cleanly yields the same query object with no errors, so
1004    // its results and scores are unchanged.
1005    let (query, parse_errors) = query_parser.parse_query_lenient(&options.query);
1006    for error in &parse_errors {
1007        tracing::debug!(
1008            "lenient parse of lexical query {:?}: {error:?}",
1009            options.query
1010        );
1011    }
1012
1013    // The content-field terms tantivy will actually match on, used only to
1014    // refine each hit's reported span (see locate_lexical_span). Taking them
1015    // from the parsed query rather than the raw string means field prefixes,
1016    // phrases, and operators are already resolved to their leaf terms.
1017    let mut span_terms: Vec<String> = Vec::new();
1018    query.query_terms(&mut |term, _| {
1019        if term.field() == content_field
1020            && let Some(text) = term.value().as_str()
1021        {
1022            let lowered = text.to_lowercase();
1023            if !span_terms.contains(&lowered) {
1024                span_terms.push(lowered);
1025            }
1026        }
1027    });
1028
1029    let top_docs = if let Some(top_k) = options.top_k {
1030        searcher.search(&query, &TopDocs::with_limit(top_k))?
1031    } else {
1032        searcher.search(&query, &TopDocs::with_limit(100))?
1033    };
1034
1035    // First, collect all results with raw scores
1036    let mut raw_results = Vec::new();
1037    for (_score, doc_address) in top_docs {
1038        let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
1039        let path_text = retrieved_doc
1040            .get_first(path_field)
1041            .map(|field_value| field_value.as_str().unwrap_or(""))
1042            .unwrap_or("");
1043        let content_text = retrieved_doc
1044            .get_first(content_field)
1045            .map(|field_value| field_value.as_str().unwrap_or(""))
1046            .unwrap_or("");
1047
1048        let file_path = PathBuf::from(path_text);
1049        if !path_matches_include(&file_path, &options.include_patterns) {
1050            continue;
1051        }
1052        let (span, preview) =
1053            locate_lexical_span(&file_path, content_text, &span_terms, options.full_section);
1054
1055        raw_results.push((
1056            _score,
1057            SearchResult {
1058                file: file_path,
1059                span,
1060                score: _score,
1061                preview,
1062                lang: ck_core::Language::from_path(&PathBuf::from(path_text)),
1063                symbol: None,
1064                chunk_hash: None,
1065                index_epoch: None,
1066            },
1067        ));
1068    }
1069
1070    // Normalize scores to 0-1 range and apply threshold
1071    let mut results = Vec::new();
1072    if !raw_results.is_empty() {
1073        let max_score = raw_results
1074            .iter()
1075            .map(|(score, _)| *score)
1076            .fold(0.0f32, f32::max);
1077        if max_score > 0.0 {
1078            for (raw_score, mut result) in raw_results {
1079                let normalized_score = raw_score / max_score;
1080
1081                // Apply threshold filtering with normalized score
1082                if let Some(threshold) = options.threshold
1083                    && normalized_score < threshold
1084                {
1085                    continue;
1086                }
1087
1088                result.score = normalized_score;
1089                results.push(result);
1090            }
1091        }
1092    }
1093
1094    Ok(results)
1095}
1096
1097/// (Re)build the tantivy index at `tantivy_index_path` over `files`.
1098/// Callers must hold the index write lock. Any existing index is replaced —
1099/// tantivy has no cheap way to diff segments against a changed corpus, and a
1100/// full text-only rebuild is fast relative to embedding work.
1101///
1102/// Searching the result happens in [`lexical_search`]; this function builds
1103/// only (its previous incarnation duplicated the entire search/read path,
1104/// which had already drifted — the rebuilt-path copy lost include filtering).
1105fn build_tantivy_index(tantivy_index_path: &Path, files: &[PathBuf]) -> Result<()> {
1106    if tantivy_index_path.exists() {
1107        fs::remove_dir_all(tantivy_index_path)?;
1108    }
1109    fs::create_dir_all(tantivy_index_path)?;
1110
1111    let mut schema_builder = Schema::builder();
1112    let content_field = schema_builder.add_text_field("content", TEXT | STORED);
1113    let path_field = schema_builder.add_text_field("path", TEXT | STORED);
1114    let schema = schema_builder.build();
1115
1116    let index = Index::create_in_dir(tantivy_index_path, schema)
1117        .map_err(|e| CkError::Index(format!("Failed to create tantivy index: {e}")))?;
1118
1119    let mut index_writer = index
1120        .writer(50_000_000)
1121        .map_err(|e| CkError::Index(format!("Failed to create index writer: {e}")))?;
1122
1123    for file_path in files {
1124        if let Ok(content) = fs::read_to_string(file_path) {
1125            let doc = doc!(
1126                content_field => content,
1127                path_field => file_path.display().to_string()
1128            );
1129            index_writer.add_document(doc)?;
1130        }
1131    }
1132
1133    index_writer
1134        .commit()
1135        .map_err(|e| CkError::Index(format!("Failed to commit index: {e}")))?;
1136
1137    Ok(())
1138}
1139
1140#[allow(dead_code)]
1141async fn hybrid_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
1142    hybrid_search_with_progress(options, None).await
1143}
1144
1145/// English filler words excluded from the keyword arm of hybrid search.
1146/// The keyword arm has no relevance scoring of its own, so terms like "the"
1147/// or "does" would flood it with matches that pollute the fused ranking.
1148const HYBRID_STOPWORDS: &[&str] = &[
1149    "the", "and", "for", "with", "that", "this", "from", "into", "when", "where", "how", "what",
1150    "why", "who", "which", "does", "are", "was", "were", "has", "have", "had", "can", "could",
1151    "should", "would", "will", "its", "use", "uses", "used", "using", "between", "over", "under",
1152    "than", "then", "them", "they", "their", "there", "your", "our", "not", "but", "all", "any",
1153    "each", "other", "some", "such", "only", "own", "same", "more", "most", "very", "happen",
1154    "happens", "get", "gets", "code", "function", "where", "place",
1155];
1156
1157/// Distinct meaningful terms from a hybrid query, for keyword matching.
1158fn hybrid_query_terms(query: &str) -> Vec<String> {
1159    let mut seen = std::collections::HashSet::new();
1160    query
1161        .split(|c: char| !c.is_alphanumeric() && c != '_')
1162        .map(str::to_lowercase)
1163        .filter(|t| t.len() >= 3 && !HYBRID_STOPWORDS.contains(&t.as_str()))
1164        .filter(|t| seen.insert(t.clone()))
1165        .collect()
1166}
1167
1168/// The keyword arm of hybrid search. Natural-language queries ("how are stale
1169/// entries cleaned up") almost never match the corpus as a literal regex,
1170/// which previously degraded hybrid search to semantic-only exactly when the
1171/// keyword boost mattered most. When the literal pattern finds nothing, retry
1172/// with a case-insensitive alternation of the query's meaningful terms and
1173/// rank the matches by how many distinct terms each line covers (the regex
1174/// engine itself has no scoring — raw traversal order would rank a line
1175/// matching one common term above a line matching all of them).
1176fn hybrid_keyword_search(options: &SearchOptions) -> Result<(Vec<SearchResult>, bool)> {
1177    let literal = regex_search(options)?;
1178    if !literal.is_empty() || options.fixed_string {
1179        return Ok((literal, false));
1180    }
1181
1182    let terms = hybrid_query_terms(&options.query);
1183    if terms.len() < 2 {
1184        return Ok((literal, false));
1185    }
1186
1187    let mut keyword_options = options.clone();
1188    keyword_options.query = terms
1189        .iter()
1190        .map(|t| regex::escape(t))
1191        .collect::<Vec<_>>()
1192        .join("|");
1193    keyword_options.case_insensitive = true;
1194    // The fallback pattern matches single terms, so it can hit far more lines
1195    // than the literal pattern would; don't let regex_search's internal top_k
1196    // cut them in traversal order before we rank them below.
1197    keyword_options.top_k = None;
1198    let matches = regex_search(&keyword_options)?;
1199
1200    // Rank matches by the rarity of the terms they contain (IDF over the
1201    // match set): a line containing a term that matched 3 lines corpus-wide
1202    // says far more than one containing a term that matched 500. Without
1203    // this, filler-adjacent terms ("results", "semantic" in a search tool's
1204    // own repo) drown the discriminative ones.
1205    let lowered: Vec<String> = matches.iter().map(|r| r.preview.to_lowercase()).collect();
1206    let doc_freq: Vec<usize> = terms
1207        .iter()
1208        .map(|t| {
1209            lowered
1210                .iter()
1211                .filter(|line| line.contains(String::as_str(t)))
1212                .count()
1213        })
1214        .collect();
1215    let total = matches.len() as f32;
1216    // String::as_str fully qualified above and below: tantivy's `Value`
1217    // trait is in scope and its `as_str(&self) -> Option<&str>` would win
1218    // method resolution.
1219    let mut scored: Vec<(f32, SearchResult)> = matches
1220        .into_iter()
1221        .zip(lowered)
1222        .map(|(r, line)| {
1223            let weight: f32 = terms
1224                .iter()
1225                .zip(&doc_freq)
1226                .filter(|(t, _)| line.contains(String::as_str(t)))
1227                .map(|(_, &df)| ((total + 1.0) / (df as f32 + 1.0)).ln())
1228                .sum();
1229            (weight, r)
1230        })
1231        .collect();
1232    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
1233    if let Some(top_k) = options.top_k {
1234        scored.truncate(top_k);
1235    }
1236    Ok((scored.into_iter().map(|(_, r)| r).collect(), true))
1237}
1238
1239/// Fuse keyword and semantic rankings with Reciprocal Rank Fusion:
1240/// RRFscore(d) = Σ(r∈R) 1/(k + r(d)), k = 60 (original paper's constant).
1241///
1242/// Results are keyed by logical location: a semantic chunk owns every keyword
1243/// hit whose line falls inside its span. (The previous exact `file:line` key
1244/// meant chunk-level semantic results and line-level keyword results never
1245/// shared a key, so nothing ever actually fused.) Each ranking contributes at
1246/// most one rank per key — its best. Fused entries keep the semantic chunk's
1247/// content, which carries more context than a single matched line.
1248/// `keyword_weight` scales the keyword arm's contribution. Literal matches
1249/// (the user's pattern actually occurs in the corpus) deserve full weight;
1250/// the synthesized term-OR fallback is a much weaker signal — at full weight
1251/// its noise demotes results the semantic ranking already had right.
1252fn rrf_fuse(
1253    keyword_results: &[SearchResult],
1254    semantic_results: &[SearchResult],
1255    keyword_weight: f32,
1256) -> Vec<SearchResult> {
1257    const RRF_K: f32 = 60.0;
1258
1259    struct Fused {
1260        result: SearchResult,
1261        keyword_rank: Option<usize>,
1262        semantic_rank: Option<usize>,
1263    }
1264
1265    // Per-file semantic spans, for mapping keyword hits into chunks
1266    let mut sem_spans: HashMap<String, Vec<(usize, usize, String)>> = HashMap::new();
1267    let mut combined: HashMap<String, Fused> = HashMap::new();
1268
1269    for (rank, result) in semantic_results.iter().enumerate() {
1270        let file = result.file.display().to_string();
1271        let key = format!(
1272            "{}:{}-{}",
1273            file, result.span.line_start, result.span.line_end
1274        );
1275        sem_spans.entry(file).or_default().push((
1276            result.span.line_start,
1277            result.span.line_end,
1278            key.clone(),
1279        ));
1280        combined.entry(key).or_insert(Fused {
1281            result: result.clone(),
1282            keyword_rank: None,
1283            semantic_rank: Some(rank + 1),
1284        });
1285    }
1286
1287    for (rank, result) in keyword_results.iter().enumerate() {
1288        let file = result.file.display().to_string();
1289        let key = sem_spans
1290            .get(&file)
1291            .and_then(|spans| {
1292                spans
1293                    .iter()
1294                    .find(|(start, end, _)| (*start..=*end).contains(&result.span.line_start))
1295                    .map(|(_, _, key)| key.clone())
1296            })
1297            .unwrap_or_else(|| format!("{}:{}", file, result.span.line_start));
1298        combined
1299            .entry(key)
1300            .and_modify(|fused| {
1301                if fused.keyword_rank.is_none() {
1302                    fused.keyword_rank = Some(rank + 1);
1303                }
1304            })
1305            .or_insert(Fused {
1306                result: result.clone(),
1307                keyword_rank: Some(rank + 1),
1308                semantic_rank: None,
1309            });
1310    }
1311
1312    combined
1313        .into_values()
1314        .map(|fused| {
1315            let mut result = fused.result;
1316            let rank_score = |rank: Option<usize>| rank.map_or(0.0, |r| 1.0 / (RRF_K + r as f32));
1317            result.score =
1318                keyword_weight * rank_score(fused.keyword_rank) + rank_score(fused.semantic_rank);
1319            result
1320        })
1321        .collect()
1322}
1323
1324async fn hybrid_search_with_progress(
1325    options: &SearchOptions,
1326    progress_callback: Option<SearchProgressCallback>,
1327) -> Result<Vec<SearchResult>> {
1328    // Fetch more candidates from each arm than the final cut: fusion can
1329    // only promote results it sees, and both regex_search and the semantic
1330    // ranking truncate to top_k internally — at the original top_k, a result
1331    // boosted by the other arm would never reach the fusion stage at all.
1332    let mut arm_options = options.clone();
1333    arm_options.top_k = options.top_k.map(|k| (k * 5).max(50));
1334
1335    if let Some(ref callback) = progress_callback {
1336        callback("Running keyword search...");
1337    }
1338    let (keyword_results, keyword_is_fallback) = hybrid_keyword_search(&arm_options)?;
1339
1340    if let Some(ref callback) = progress_callback {
1341        callback("Running semantic search...");
1342    }
1343    let semantic_results =
1344        semantic_search_v3_with_progress(&arm_options, progress_callback).await?;
1345
1346    let keyword_weight = if keyword_is_fallback { 0.3 } else { 1.0 };
1347    let mut rrf_results = rrf_fuse(&keyword_results, &semantic_results.matches, keyword_weight);
1348
1349    // Apply threshold filtering to raw RRF scores
1350    if let Some(threshold) = options.threshold {
1351        rrf_results.retain(|result| result.score >= threshold);
1352    }
1353
1354    rrf_results.retain(|result| path_matches_include(&result.file, &options.include_patterns));
1355
1356    // Sort by RRF score (highest first)
1357    rrf_results.sort_by(|a, b| {
1358        b.score
1359            .partial_cmp(&a.score)
1360            .unwrap_or(std::cmp::Ordering::Equal)
1361    });
1362
1363    if let Some(top_k) = options.top_k {
1364        rrf_results.truncate(top_k);
1365    }
1366
1367    Ok(rrf_results)
1368}
1369
1370fn build_globset(patterns: &[String]) -> GlobSet {
1371    let mut builder = GlobSetBuilder::new();
1372    for pat in patterns {
1373        // Treat patterns as filename or directory globs
1374        if let Ok(glob) = Glob::new(pat) {
1375            builder.add(glob);
1376        }
1377    }
1378    builder.build().unwrap_or_else(|_| GlobSet::empty())
1379}
1380
1381fn should_exclude_path(path: &Path, globset: &GlobSet) -> bool {
1382    // Match against each path component and the full path
1383    if globset.is_match(path) {
1384        return true;
1385    }
1386    for component in path.components() {
1387        if let std::path::Component::Normal(name) = component
1388            && globset.is_match(name)
1389        {
1390            return true;
1391        }
1392    }
1393    false
1394}
1395
1396fn collect_files(
1397    path: &Path,
1398    recursive: bool,
1399    exclude_patterns: &[String],
1400) -> Result<Vec<PathBuf>> {
1401    let mut files = Vec::new();
1402    let globset = build_globset(exclude_patterns);
1403
1404    if path.is_file() {
1405        // Always add single files, even if they're excluded (user explicitly requested)
1406        files.push(path.to_path_buf());
1407    } else if recursive {
1408        for entry in WalkDir::new(path).into_iter().filter_entry(|e| {
1409            // Skip excluded directories entirely for efficiency
1410            let name = e.file_name();
1411            !globset.is_match(e.path()) && !globset.is_match(name)
1412        }) {
1413            match entry {
1414                Ok(entry) => {
1415                    if entry.file_type().is_file() && !should_exclude_path(entry.path(), &globset) {
1416                        files.push(entry.path().to_path_buf());
1417                    }
1418                }
1419                Err(e) => {
1420                    // Log directory traversal errors but continue processing
1421                    tracing::debug!("Skipping path due to error: {}", e);
1422                    continue;
1423                }
1424            }
1425        }
1426    } else {
1427        match fs::read_dir(path) {
1428            Ok(read_dir) => {
1429                for entry in read_dir {
1430                    match entry {
1431                        Ok(entry) => {
1432                            let path = entry.path();
1433                            if path.is_file() && !should_exclude_path(&path, &globset) {
1434                                files.push(path);
1435                            }
1436                        }
1437                        Err(e) => {
1438                            tracing::debug!("Skipping directory entry due to error: {}", e);
1439                            continue;
1440                        }
1441                    }
1442                }
1443            }
1444            Err(e) => {
1445                tracing::debug!("Cannot read directory {:?}: {}", path, e);
1446                return Err(e.into());
1447            }
1448        }
1449    }
1450
1451    Ok(files)
1452}
1453
1454/// Returns the indexing stats when a directory-level smart update ran, or
1455/// `None` for the single-file fast path (which reports no stats).
1456async fn ensure_index_updated_with_progress(
1457    path: &Path,
1458    force_reindex: bool,
1459    need_embeddings: bool,
1460    progress_callback: Option<ck_index::ProgressCallback>,
1461    detailed_progress_callback: Option<ck_index::DetailedProgressCallback>,
1462    file_options: &ck_core::FileCollectionOptions,
1463    model_override: Option<&str>,
1464) -> Result<Option<ck_index::UpdateStats>> {
1465    // Find index root for .ck directory location
1466    let index_root_buf = find_nearest_index_root(path).unwrap_or_else(|| {
1467        if path.is_file() {
1468            path.parent().unwrap_or(path).to_path_buf()
1469        } else {
1470            path.to_path_buf()
1471        }
1472    });
1473    let index_root = &index_root_buf;
1474
1475    // Pass the original path to indexing function so it can index just that file/directory
1476    // The indexing function will use collect_files() which now handles individual files correctly
1477    if force_reindex {
1478        let stats = ck_index::smart_update_index_with_detailed_progress(
1479            index_root,
1480            true,
1481            progress_callback,
1482            detailed_progress_callback,
1483            need_embeddings,
1484            file_options,
1485            model_override,
1486        )
1487        .await?;
1488        if stats.files_indexed > 0 || stats.orphaned_files_removed > 0 {
1489            tracing::info!(
1490                "Index updated: {} files indexed, {} orphaned files removed",
1491                stats.files_indexed,
1492                stats.orphaned_files_removed
1493            );
1494        }
1495        return Ok(Some(stats));
1496    }
1497
1498    // For incremental updates with individual files, we need special handling
1499    // to ensure only the specific file is indexed, not the entire directory
1500    if path.is_file() {
1501        // Index just this one file
1502        use ck_index::index_file;
1503        index_file(path, need_embeddings).await?;
1504        Ok(None)
1505    } else {
1506        // For directories, use the standard smart update
1507        let stats = ck_index::smart_update_index_with_detailed_progress(
1508            index_root,
1509            false,
1510            progress_callback,
1511            detailed_progress_callback,
1512            need_embeddings,
1513            file_options,
1514            model_override,
1515        )
1516        .await?;
1517        if stats.files_indexed > 0 || stats.orphaned_files_removed > 0 {
1518            tracing::info!(
1519                "Index updated: {} files indexed, {} orphaned files removed",
1520                stats.files_indexed,
1521                stats.orphaned_files_removed
1522            );
1523        }
1524        Ok(Some(stats))
1525    }
1526}
1527
1528fn get_context_preview(lines: &[String], line_idx: usize, options: &SearchOptions) -> String {
1529    let before = options.before_context_lines.max(options.context_lines);
1530    let after = options.after_context_lines.max(options.context_lines);
1531
1532    if before > 0 || after > 0 {
1533        let start_idx = line_idx.saturating_sub(before);
1534        let end_idx = (line_idx + after + 1).min(lines.len());
1535        lines[start_idx..end_idx].join("\n")
1536    } else {
1537        lines[line_idx].to_string()
1538    }
1539}
1540
1541fn extract_code_sections(file_path: &Path, content: &str) -> Option<Vec<(usize, usize, String)>> {
1542    let lang = ck_core::Language::from_path(file_path)?;
1543
1544    // Parse the file with tree-sitter and extract function/class sections
1545    if let Ok(chunks) = ck_chunk::chunk_text(content, Some(lang)) {
1546        let include_markdown = lang == ck_core::Language::Markdown;
1547        let sections: Vec<(usize, usize, String)> = chunks
1548            .into_iter()
1549            .filter(|chunk| {
1550                if include_markdown {
1551                    matches!(
1552                        chunk.chunk_type,
1553                        ck_chunk::ChunkType::Module | ck_chunk::ChunkType::Text
1554                    )
1555                } else {
1556                    matches!(
1557                        chunk.chunk_type,
1558                        ck_chunk::ChunkType::Function
1559                            | ck_chunk::ChunkType::Class
1560                            | ck_chunk::ChunkType::Method
1561                    )
1562                }
1563            })
1564            .map(|chunk| {
1565                (
1566                    chunk.span.line_start - 1, // Convert to 0-based index
1567                    chunk.span.line_end - 1,
1568                    chunk.text,
1569                )
1570            })
1571            .collect();
1572
1573        if sections.is_empty() {
1574            None
1575        } else {
1576            Some(sections)
1577        }
1578    } else {
1579        None
1580    }
1581}
1582
1583fn find_containing_section(
1584    sections: &[(usize, usize, String)],
1585    line_idx: usize,
1586) -> Option<&String> {
1587    for (start, end, text) in sections {
1588        if line_idx >= *start && line_idx <= *end {
1589            return Some(text);
1590        }
1591    }
1592    None
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597    use super::*;
1598    use std::fs;
1599    use tempfile::TempDir;
1600
1601    fn make_result(file: &str, line_start: usize, line_end: usize, preview: &str) -> SearchResult {
1602        SearchResult {
1603            file: PathBuf::from(file),
1604            span: Span {
1605                byte_start: 0,
1606                byte_end: 0,
1607                line_start,
1608                line_end,
1609            },
1610            score: 0.0,
1611            preview: preview.to_string(),
1612            lang: None,
1613            symbol: None,
1614            chunk_hash: None,
1615            index_epoch: None,
1616        }
1617    }
1618
1619    #[test]
1620    fn test_hybrid_query_terms_filters_stopwords_and_dedupes() {
1621        let terms =
1622            hybrid_query_terms("How does the RRF rank fusion merge rank results from regex?");
1623        assert_eq!(
1624            terms,
1625            vec!["rrf", "rank", "fusion", "merge", "results", "regex"]
1626        );
1627
1628        // Single short/stopword-only queries produce nothing
1629        assert!(hybrid_query_terms("how does the it").is_empty());
1630    }
1631
1632    #[test]
1633    fn test_rrf_fuse_merges_keyword_hit_into_containing_semantic_chunk() {
1634        // Semantic chunk spans lines 10-50; keyword hit lands on line 20
1635        let semantic = vec![
1636            make_result("src/a.rs", 10, 50, "fn fuse() { /* rrf */ }"),
1637            make_result("src/b.rs", 1, 5, "unrelated chunk"),
1638        ];
1639        let keyword = vec![make_result("src/a.rs", 20, 20, "let rrf_score = ranks")];
1640
1641        let fused = rrf_fuse(&keyword, &semantic, 1.0);
1642
1643        // The keyword hit fused into the chunk: 3 inputs, 2 outputs
1644        assert_eq!(fused.len(), 2);
1645        let chunk = fused
1646            .iter()
1647            .find(|r| r.file == Path::new("src/a.rs"))
1648            .unwrap();
1649        // Chunk-level span retained, score = both lists at rank 1
1650        assert_eq!((chunk.span.line_start, chunk.span.line_end), (10, 50));
1651        let expected = 1.0 / 61.0 + 1.0 / 61.0;
1652        assert!((chunk.score - expected).abs() < 1e-6);
1653
1654        // The fused result must outrank the semantic-only one
1655        let other = fused
1656            .iter()
1657            .find(|r| r.file == Path::new("src/b.rs"))
1658            .unwrap();
1659        assert!(chunk.score > other.score);
1660    }
1661
1662    #[test]
1663    fn test_rrf_fuse_keyword_only_hit_keeps_own_identity() {
1664        let semantic = vec![make_result("src/a.rs", 10, 50, "chunk")];
1665        let keyword = vec![make_result("src/z.rs", 7, 7, "standalone line")];
1666
1667        let fused = rrf_fuse(&keyword, &semantic, 1.0);
1668        assert_eq!(fused.len(), 2);
1669        let standalone = fused
1670            .iter()
1671            .find(|r| r.file == Path::new("src/z.rs"))
1672            .unwrap();
1673        assert!((standalone.score - 1.0 / 61.0).abs() < 1e-6);
1674    }
1675
1676    #[test]
1677    fn test_rrf_fuse_counts_each_list_once_per_key() {
1678        // Two keyword hits inside the same semantic chunk: only the best
1679        // keyword rank contributes, not both.
1680        let semantic = vec![make_result("src/a.rs", 10, 50, "chunk")];
1681        let keyword = vec![
1682            make_result("src/a.rs", 12, 12, "first hit"),
1683            make_result("src/a.rs", 40, 40, "second hit"),
1684        ];
1685
1686        let fused = rrf_fuse(&keyword, &semantic, 1.0);
1687        assert_eq!(fused.len(), 1);
1688        let expected = 1.0 / 61.0 + 1.0 / 61.0; // sem rank 1 + best keyword rank 1
1689        assert!((fused[0].score - expected).abs() < 1e-6);
1690    }
1691
1692    #[test]
1693    fn test_lexical_corpus_fingerprint_tracks_changes() {
1694        let temp_dir = TempDir::new().unwrap();
1695        let a = temp_dir.path().join("a.txt");
1696        let b = temp_dir.path().join("b.txt");
1697        fs::write(&a, "one").unwrap();
1698        fs::write(&b, "two").unwrap();
1699
1700        let original = lexical_corpus_fingerprint(&[a.clone(), b.clone()]);
1701
1702        // Order-insensitive
1703        assert_eq!(
1704            original,
1705            lexical_corpus_fingerprint(&[b.clone(), a.clone()])
1706        );
1707
1708        // Content change (different size) changes the fingerprint
1709        fs::write(&a, "one but longer").unwrap();
1710        assert_ne!(
1711            original,
1712            lexical_corpus_fingerprint(&[a.clone(), b.clone()])
1713        );
1714
1715        // Removing a file changes the fingerprint
1716        let shrunk = lexical_corpus_fingerprint(std::slice::from_ref(&a));
1717        assert_ne!(shrunk, lexical_corpus_fingerprint(&[a, b]));
1718    }
1719
1720    fn create_test_files(dir: &std::path::Path) -> Vec<PathBuf> {
1721        let files = vec![
1722            ("test1.txt", "hello world rust programming"),
1723            ("test2.rs", "fn main() { println!(\"Hello Rust\"); }"),
1724            ("test3.py", "print('Hello Python')"),
1725            ("test4.txt", "machine learning artificial intelligence"),
1726        ];
1727
1728        let mut paths = Vec::new();
1729        for (name, content) in files {
1730            let path = dir.join(name);
1731            fs::write(&path, content).unwrap();
1732            paths.push(path);
1733        }
1734        paths
1735    }
1736
1737    #[test]
1738    fn test_extract_lines_from_file() {
1739        let temp_dir = TempDir::new().unwrap();
1740        let test_file = temp_dir.path().join("test_lines.txt");
1741
1742        // Create a multi-line test file
1743        let content =
1744            "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10";
1745        fs::write(&test_file, content).unwrap();
1746
1747        // Test extracting lines 3-5 (1-based indexing)
1748        let result = extract_lines_from_file(&test_file, 3, 5).unwrap();
1749        assert_eq!(result, "Line 3\nLine 4\nLine 5");
1750
1751        // Test extracting a single line
1752        let result = extract_lines_from_file(&test_file, 7, 7).unwrap();
1753        assert_eq!(result, "Line 7");
1754
1755        // Test extracting from line 8 to end
1756        let result = extract_lines_from_file(&test_file, 8, 100).unwrap();
1757        assert_eq!(result, "Line 8\nLine 9\nLine 10");
1758
1759        // Test line_start == 0 (should return empty)
1760        let result = extract_lines_from_file(&test_file, 0, 5).unwrap();
1761        assert_eq!(result, "");
1762
1763        // Test line_start > file length (should return empty)
1764        let result = extract_lines_from_file(&test_file, 20, 25).unwrap();
1765        assert_eq!(result, "");
1766    }
1767
1768    #[tokio::test]
1769    async fn test_extract_content_from_span() {
1770        let temp_dir = TempDir::new().unwrap();
1771        let test_file = temp_dir.path().join("code.rs");
1772
1773        // Create a multi-line code file
1774        let content = "fn first() {\n    println!(\"First\");\n}\n\nfn second() {\n    println!(\"Second\");\n}\n\nfn third() {\n    println!(\"Third\");\n}";
1775        fs::write(&test_file, content).unwrap();
1776
1777        // Test extracting the second function (lines 5-7)
1778        let span = ck_core::Span {
1779            byte_start: 0, // Not used in line extraction
1780            byte_end: 0,   // Not used in line extraction
1781            line_start: 5,
1782            line_end: 7,
1783        };
1784
1785        let result = extract_content_from_span(&test_file, &span).await.unwrap();
1786        assert_eq!(result, "fn second() {\n    println!(\"Second\");\n}");
1787
1788        // Test extracting a single line
1789        let span = ck_core::Span {
1790            byte_start: 0,
1791            byte_end: 0,
1792            line_start: 2,
1793            line_end: 2,
1794        };
1795
1796        let result = extract_content_from_span(&test_file, &span).await.unwrap();
1797        assert_eq!(result, "    println!(\"First\");");
1798    }
1799
1800    #[test]
1801    fn test_collect_files() {
1802        let temp_dir = TempDir::new().unwrap();
1803        let test_files = create_test_files(temp_dir.path());
1804
1805        // Test non-recursive
1806        let files = collect_files(temp_dir.path(), false, &[]).unwrap();
1807        assert_eq!(files.len(), 4);
1808
1809        // Test recursive
1810        let files = collect_files(temp_dir.path(), true, &[]).unwrap();
1811        assert_eq!(files.len(), 4);
1812
1813        // Test single file
1814        let files = collect_files(&test_files[0], false, &[]).unwrap();
1815        assert_eq!(files.len(), 1);
1816        assert_eq!(files[0], test_files[0]);
1817    }
1818
1819    #[test]
1820    fn test_regex_search() {
1821        let temp_dir = TempDir::new().unwrap();
1822        create_test_files(temp_dir.path());
1823
1824        let options = SearchOptions {
1825            mode: SearchMode::Regex,
1826            query: "rust".to_string(),
1827            path: temp_dir.path().to_path_buf(),
1828            recursive: true,
1829            ..Default::default()
1830        };
1831
1832        let results = regex_search(&options).unwrap();
1833        assert!(!results.is_empty());
1834
1835        // Should find matches in files containing "rust"
1836        let rust_matches: Vec<_> = results
1837            .iter()
1838            .filter(|r| r.preview.to_lowercase().contains("rust"))
1839            .collect();
1840        assert!(!rust_matches.is_empty());
1841    }
1842
1843    #[test]
1844    fn test_regex_search_case_insensitive() {
1845        let temp_dir = TempDir::new().unwrap();
1846        create_test_files(temp_dir.path());
1847
1848        let options = SearchOptions {
1849            mode: SearchMode::Regex,
1850            query: "HELLO".to_string(),
1851            path: temp_dir.path().to_path_buf(),
1852            recursive: true,
1853            case_insensitive: true,
1854            ..Default::default()
1855        };
1856
1857        let results = regex_search(&options).unwrap();
1858        assert!(!results.is_empty());
1859    }
1860
1861    #[test]
1862    fn test_regex_search_fixed_string() {
1863        let temp_dir = TempDir::new().unwrap();
1864        create_test_files(temp_dir.path());
1865
1866        let options = SearchOptions {
1867            mode: SearchMode::Regex,
1868            query: "fn main()".to_string(),
1869            path: temp_dir.path().to_path_buf(),
1870            recursive: true,
1871            fixed_string: true,
1872            ..Default::default()
1873        };
1874
1875        let results = regex_search(&options).unwrap();
1876        assert!(!results.is_empty());
1877    }
1878
1879    #[test]
1880    fn test_regex_search_whole_word() {
1881        let temp_dir = TempDir::new().unwrap();
1882        fs::write(
1883            temp_dir.path().join("word_test.txt"),
1884            "rust rusty rustacean",
1885        )
1886        .unwrap();
1887
1888        let options = SearchOptions {
1889            mode: SearchMode::Regex,
1890            query: "rust".to_string(),
1891            path: temp_dir.path().to_path_buf(),
1892            recursive: true,
1893            whole_word: true,
1894            ..Default::default()
1895        };
1896
1897        let results = regex_search(&options).unwrap();
1898        assert!(!results.is_empty());
1899        // Should only match "rust" as a whole word, not "rusty" or "rustacean"
1900    }
1901
1902    #[test]
1903    fn test_regex_search_top_k() {
1904        let temp_dir = TempDir::new().unwrap();
1905
1906        // Create multiple files with matches
1907        for i in 0..10 {
1908            fs::write(temp_dir.path().join(format!("file{i}.txt")), "test content").unwrap();
1909        }
1910
1911        let options = SearchOptions {
1912            mode: SearchMode::Regex,
1913            query: "test".to_string(),
1914            path: temp_dir.path().to_path_buf(),
1915            recursive: true,
1916            top_k: Some(5),
1917            ..Default::default()
1918        };
1919
1920        let results = regex_search(&options).unwrap();
1921        assert!(results.len() <= 5);
1922    }
1923
1924    #[test]
1925    fn test_regex_search_span_offsets() {
1926        // Test that span offsets are correctly calculated for multiple matches on a line
1927        let temp_dir = TempDir::new().unwrap();
1928        let test_file = temp_dir.path().join("spans.txt");
1929        fs::write(&test_file, "test test test\nline two test\ntest end").unwrap();
1930
1931        let options = SearchOptions {
1932            mode: SearchMode::Regex,
1933            query: "test".to_string(),
1934            path: test_file.clone(),
1935            recursive: false,
1936            ..Default::default()
1937        };
1938
1939        let results = regex_search(&options).unwrap();
1940
1941        // Should find 5 matches total
1942        assert_eq!(results.len(), 5);
1943
1944        // Check first line has 3 matches with correct byte offsets
1945        let line1_matches: Vec<_> = results.iter().filter(|r| r.span.line_start == 1).collect();
1946        assert_eq!(line1_matches.len(), 3);
1947        assert_eq!(line1_matches[0].span.byte_start, 0);
1948        assert_eq!(line1_matches[1].span.byte_start, 5);
1949        assert_eq!(line1_matches[2].span.byte_start, 10);
1950
1951        // Check second line match
1952        let line2_matches: Vec<_> = results.iter().filter(|r| r.span.line_start == 2).collect();
1953        assert_eq!(line2_matches.len(), 1);
1954        assert_eq!(line2_matches[0].span.byte_start, 24); // "test test test\n" = 15 bytes, "line two " = 9 bytes
1955
1956        // Each match should have different byte offsets
1957        let mut byte_starts: Vec<_> = results.iter().map(|r| r.span.byte_start).collect();
1958        byte_starts.sort();
1959        byte_starts.dedup();
1960        assert_eq!(byte_starts.len(), 5); // All byte_starts should be unique
1961    }
1962
1963    #[test]
1964    fn test_search_file() {
1965        let temp_dir = TempDir::new().unwrap();
1966        let file_path = temp_dir.path().join("test.txt");
1967        fs::write(
1968            &file_path,
1969            "line 1: hello\nline 2: world\nline 3: rust programming",
1970        )
1971        .unwrap();
1972
1973        let regex = regex::Regex::new("rust").unwrap();
1974        let options = SearchOptions::default();
1975
1976        let results = search_file(&regex, &file_path, &options).unwrap();
1977        assert_eq!(results.len(), 1);
1978        assert_eq!(results[0].span.line_start, 3);
1979        assert!(results[0].preview.contains("rust"));
1980    }
1981
1982    #[test]
1983    fn test_search_file_with_context() {
1984        let temp_dir = TempDir::new().unwrap();
1985        let file_path = temp_dir.path().join("test.txt");
1986        fs::write(&file_path, "line 1\nline 2\ntarget line\nline 4\nline 5").unwrap();
1987
1988        let regex = regex::Regex::new("target").unwrap();
1989        let options = SearchOptions {
1990            context_lines: 1,
1991            ..Default::default()
1992        };
1993
1994        let results = search_file(&regex, &file_path, &options).unwrap();
1995        assert_eq!(results.len(), 1);
1996
1997        println!("Preview: '{}'", results[0].preview);
1998
1999        // The target line is line 3, with 1 context line before and after
2000        // So we should get lines 2, 3, 4
2001        assert!(results[0].preview.contains("line 2"));
2002        assert!(results[0].preview.contains("target line"));
2003        assert!(results[0].preview.contains("line 4"));
2004    }
2005
2006    #[tokio::test]
2007    async fn test_search_main_function() {
2008        let temp_dir = TempDir::new().unwrap();
2009        create_test_files(temp_dir.path());
2010
2011        let options = SearchOptions {
2012            mode: SearchMode::Regex,
2013            query: "hello".to_string(),
2014            path: temp_dir.path().to_path_buf(),
2015            recursive: true,
2016            case_insensitive: true,
2017            ..Default::default()
2018        };
2019
2020        let results = search(&options).await.unwrap();
2021        assert!(!results.is_empty());
2022    }
2023
2024    #[tokio::test]
2025    async fn test_regex_search_mixed_line_endings() {
2026        // Regression test for byte offset issues with different line endings
2027        let temp_dir = TempDir::new().unwrap();
2028
2029        // Create test file with mixed line endings (Windows \r\n and Unix \n)
2030        let test_file = temp_dir.path().join("mixed_endings.txt");
2031        let content = "line1\r\nline2\nline3\r\npattern here\nline5\r\n";
2032        std::fs::write(&test_file, content).unwrap();
2033
2034        let options = SearchOptions {
2035            mode: SearchMode::Regex,
2036            query: "pattern".to_string(),
2037            path: test_file.clone(),
2038            recursive: false,
2039            ..Default::default()
2040        };
2041
2042        let results = search(&options).await.unwrap();
2043        assert_eq!(results.len(), 1);
2044
2045        let result = &results[0];
2046        // Verify byte offsets are correct - should point to start of "pattern"
2047        let original_content = std::fs::read_to_string(&test_file).unwrap();
2048        let pattern_start = original_content.find("pattern").unwrap();
2049
2050        assert_eq!(result.span.byte_start, pattern_start);
2051        assert_eq!(result.span.line_start, 4); // Fourth line
2052    }
2053
2054    #[tokio::test]
2055    async fn test_regex_search_windows_line_endings() {
2056        // Regression test specifically for Windows \r\n line endings
2057        let temp_dir = TempDir::new().unwrap();
2058
2059        let test_file = temp_dir.path().join("windows_endings.txt");
2060        let content = "first line\r\nsecond line\r\nmatch this\r\nfourth line\r\n";
2061        std::fs::write(&test_file, content).unwrap();
2062
2063        let options = SearchOptions {
2064            mode: SearchMode::Regex,
2065            query: "match".to_string(),
2066            path: test_file.clone(),
2067            recursive: false,
2068            ..Default::default()
2069        };
2070
2071        let results = search(&options).await.unwrap();
2072        assert_eq!(results.len(), 1);
2073
2074        let result = &results[0];
2075
2076        // Verify the match is on line 3
2077        assert_eq!(result.span.line_start, 3);
2078
2079        // Verify byte offset accounts for \r\n endings
2080        // first line\r\n = 12 bytes, second line\r\n = 13 bytes, total = 25 bytes before "match"
2081        let expected_byte_start = 25; // Position of "match" in the content
2082        assert_eq!(result.span.byte_start, expected_byte_start);
2083    }
2084
2085    #[test]
2086    fn test_split_lines_with_endings_helper() {
2087        // Unix line endings
2088        let unix_content = "line1\nline2\nline3\n";
2089        let (unix_lines, unix_endings) = split_lines_with_endings(unix_content);
2090        assert_eq!(unix_lines, vec!["line1", "line2", "line3"]);
2091        assert_eq!(unix_endings, vec![1, 1, 1]);
2092
2093        // Windows line endings
2094        let windows_content = "line1\r\nline2\r\nline3\r\n";
2095        let (windows_lines, windows_endings) = split_lines_with_endings(windows_content);
2096        assert_eq!(windows_lines, vec!["line1", "line2", "line3"]);
2097        assert_eq!(windows_endings, vec![2, 2, 2]);
2098
2099        // Old Mac line endings
2100        let mac_content = "line1\rline2\rline3\r";
2101        let (mac_lines, mac_endings) = split_lines_with_endings(mac_content);
2102        assert_eq!(mac_lines, vec!["line1", "line2", "line3"]);
2103        assert_eq!(mac_endings, vec![1, 1, 1]);
2104
2105        // Mixed endings
2106        let mixed_content = "line1\nline2\r\nline3\r";
2107        let (mixed_lines, mixed_endings) = split_lines_with_endings(mixed_content);
2108        assert_eq!(mixed_lines, vec!["line1", "line2", "line3"]);
2109        assert_eq!(mixed_endings, vec![1, 2, 1]);
2110
2111        // No line endings
2112        let no_endings = "single line";
2113        let (no_lines, no_endings_vec) = split_lines_with_endings(no_endings);
2114        assert_eq!(no_lines, vec!["single line"]);
2115        assert_eq!(no_endings_vec, vec![0]);
2116    }
2117
2118    // Default model config is fastembed; without that feature ck-embed
2119    // falls back to DummyEmbedder (zero vectors), so semantic search
2120    // returns nothing and these tests have nothing to assert against.
2121    #[cfg(feature = "fastembed")]
2122    #[tokio::test]
2123    async fn test_subdirectory_search_uses_parent_ckignore() {
2124        // Regression test for issue where searching in subdirectory doesn't use parent .ckignore
2125        // Bug: When searching ~/parent/subdir/, .ckignore is loaded from subdir (doesn't exist)
2126        // instead of from parent (where index and .ckignore live)
2127
2128        let temp_dir = TempDir::new().unwrap();
2129        let parent = temp_dir.path();
2130        let subdir = parent.join("subproject");
2131        fs::create_dir(&subdir).unwrap();
2132
2133        // Create .ckignore at parent level excluding *.tmp files
2134        fs::write(parent.join(".ckignore"), "*.tmp\n").unwrap();
2135
2136        // Create test files in parent directory
2137        fs::write(parent.join("parent.txt"), "searchable content in parent").unwrap();
2138        fs::write(parent.join("ignored.tmp"), "this should not be indexed").unwrap();
2139
2140        // Create test files in subdirectory
2141        fs::write(subdir.join("nested.txt"), "searchable content in subdir").unwrap();
2142        fs::write(
2143            subdir.join("also_ignored.tmp"),
2144            "this should not be indexed either",
2145        )
2146        .unwrap();
2147
2148        // First, search from parent to create the index
2149        let parent_options = SearchOptions {
2150            mode: SearchMode::Semantic,
2151            query: "searchable".to_string(),
2152            path: parent.to_path_buf(),
2153            top_k: Some(10),
2154            threshold: Some(0.1),
2155            ..Default::default()
2156        };
2157
2158        let _ = search(&parent_options).await;
2159
2160        // Give indexing a moment to complete
2161        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2162
2163        // Now search from SUBDIRECTORY - this is where the bug occurs
2164        // The engine should find parent .ck index and use parent .ckignore
2165        // But currently it loads .ckignore from subdir (doesn't exist)
2166        let subdir_options = SearchOptions {
2167            mode: SearchMode::Semantic,
2168            query: "content".to_string(),
2169            path: subdir.clone(),
2170            top_k: Some(10),
2171            threshold: Some(0.1),
2172            ..Default::default()
2173        };
2174
2175        let results = search(&subdir_options).await.unwrap();
2176
2177        // ASSERTION 1: .tmp files should be excluded (currently FAILS due to bug)
2178        let tmp_files: Vec<_> = results
2179            .iter()
2180            .filter(|r| r.file.to_string_lossy().ends_with(".tmp"))
2181            .collect();
2182        assert!(
2183            tmp_files.is_empty(),
2184            "Bug: .tmp files were indexed despite parent .ckignore. Found {} .tmp files: {:?}",
2185            tmp_files.len(),
2186            tmp_files.iter().map(|r| &r.file).collect::<Vec<_>>()
2187        );
2188
2189        // ASSERTION 2: Should find .txt files in subdirectory
2190        let txt_in_subdir = results.iter().any(|r| r.file.ends_with("nested.txt"));
2191        assert!(txt_in_subdir, "Should find nested.txt in subdirectory");
2192
2193        // ASSERTION 3: No .ck directory should be created in subdirectory
2194        assert!(
2195            !subdir.join(".ck").exists(),
2196            "Should not create .ck directory in subdirectory"
2197        );
2198    }
2199
2200    // Default model config is fastembed; without that feature ck-embed
2201    // falls back to DummyEmbedder (zero vectors), so semantic search
2202    // returns nothing and these tests have nothing to assert against.
2203    #[cfg(feature = "fastembed")]
2204    #[tokio::test]
2205    async fn test_multiple_ckignore_files_merge_correctly() {
2206        // Test that multiple .ckignore files in the hierarchy are all applied
2207        use std::fs;
2208        use tempfile::TempDir;
2209
2210        let temp_dir = TempDir::new().unwrap();
2211        let parent = temp_dir.path();
2212        let subdir = parent.join("subdir");
2213        let deeper = subdir.join("deeper");
2214        fs::create_dir(&subdir).unwrap();
2215        fs::create_dir(&deeper).unwrap();
2216
2217        // Create hierarchical .ckignore files
2218        fs::write(parent.join(".ckignore"), "*.log\n").unwrap();
2219        fs::write(subdir.join(".ckignore"), "*.tmp\n").unwrap();
2220        fs::write(deeper.join(".ckignore"), "*.cache\n").unwrap();
2221
2222        // Create test files at each level
2223        fs::write(parent.join("root.txt"), "searchable").unwrap();
2224        fs::write(parent.join("root.log"), "should be ignored").unwrap();
2225
2226        fs::write(subdir.join("mid.txt"), "searchable").unwrap();
2227        fs::write(subdir.join("mid.log"), "should be ignored by parent").unwrap();
2228        fs::write(subdir.join("mid.tmp"), "should be ignored by local").unwrap();
2229
2230        fs::write(deeper.join("deep.txt"), "searchable").unwrap();
2231        fs::write(deeper.join("deep.log"), "should be ignored by grandparent").unwrap();
2232        fs::write(deeper.join("deep.tmp"), "should be ignored by parent").unwrap();
2233        fs::write(deeper.join("deep.cache"), "should be ignored by local").unwrap();
2234
2235        // Index from parent
2236        let parent_options = SearchOptions {
2237            mode: SearchMode::Semantic,
2238            query: "searchable".to_string(),
2239            path: parent.to_path_buf(),
2240            top_k: Some(20),
2241            threshold: Some(0.1),
2242            ..Default::default()
2243        };
2244
2245        let _ = search(&parent_options).await;
2246        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2247
2248        // Search from deeper directory - should respect ALL three .ckignore files
2249        let deeper_options = SearchOptions {
2250            mode: SearchMode::Semantic,
2251            query: "ignored".to_string(),
2252            path: deeper.clone(),
2253            top_k: Some(20),
2254            threshold: Some(0.1),
2255            ..Default::default()
2256        };
2257
2258        let results = search(&deeper_options).await.unwrap();
2259
2260        // All ignored files should be excluded
2261        let has_log = results
2262            .iter()
2263            .any(|r| r.file.to_string_lossy().ends_with(".log"));
2264        let has_tmp = results
2265            .iter()
2266            .any(|r| r.file.to_string_lossy().ends_with(".tmp"));
2267        let has_cache = results
2268            .iter()
2269            .any(|r| r.file.to_string_lossy().ends_with(".cache"));
2270
2271        assert!(
2272            !has_log,
2273            "*.log files should be excluded by parent .ckignore"
2274        );
2275        assert!(
2276            !has_tmp,
2277            "*.tmp files should be excluded by subdir .ckignore"
2278        );
2279        assert!(
2280            !has_cache,
2281            "*.cache files should be excluded by deeper .ckignore"
2282        );
2283
2284        // Should still find .txt files
2285        let has_txt = results
2286            .iter()
2287            .any(|r| r.file.to_string_lossy().ends_with(".txt"));
2288        assert!(has_txt, "Should find .txt files (not ignored)");
2289    }
2290
2291    // Default model config is fastembed; without that feature ck-embed
2292    // falls back to DummyEmbedder (zero vectors) and the assertions can't
2293    // distinguish a scoped match from no match at all.
2294    #[cfg(feature = "fastembed")]
2295    #[tokio::test]
2296    async fn test_scoped_search_does_not_lose_results_to_global_top_k() {
2297        // Regression test for the bug where scoped semantic search applied
2298        // top_k BEFORE the path filter, so a small top_k against a whole-
2299        // codebase index could return zero matches when the global top
2300        // results all lived outside the requested scope.
2301        //
2302        // Reproduction:
2303        //   - Index a parent dir that contains many files about TOPIC_A
2304        //     (so they dominate the global top_k for that query)
2305        //   - Search inside a sibling subdir that contains a file about
2306        //     TOPIC_A, with top_k smaller than the TOPIC_A file count
2307        //   - Before the fix: zero results inside subdir
2308        //   - After the fix:  the in-scope file is returned
2309        use std::fs;
2310        use tempfile::TempDir;
2311
2312        let temp_dir = TempDir::new().unwrap();
2313        let parent = temp_dir.path();
2314        let noisy = parent.join("noisy");
2315        let scoped = parent.join("scoped");
2316        fs::create_dir(&noisy).unwrap();
2317        fs::create_dir(&scoped).unwrap();
2318
2319        // 8 files in noisy/ that all match the query "database connection".
2320        // top_k=3 will be entirely consumed by these globally.
2321        for i in 0..8 {
2322            fs::write(
2323                noisy.join(format!("noise_{i}.txt")),
2324                format!(
2325                    "function open_database_connection_{i}() {{\n    \
2326                     // establish a database connection to postgres\n    \
2327                     // handle database connection errors gracefully\n}}\n"
2328                ),
2329            )
2330            .unwrap();
2331        }
2332
2333        // One in-scope file that also matches the query
2334        fs::write(
2335            scoped.join("target.txt"),
2336            "function connect() {\n    \
2337             // open a database connection to the primary store\n    \
2338             // database connection pool config goes here\n}\n",
2339        )
2340        .unwrap();
2341
2342        // Index from parent so .ck lives at parent root and covers both subdirs.
2343        let index_options = SearchOptions {
2344            mode: SearchMode::Semantic,
2345            query: "database connection".to_string(),
2346            path: parent.to_path_buf(),
2347            top_k: Some(20),
2348            threshold: Some(0.0),
2349            ..Default::default()
2350        };
2351        let _ = search(&index_options).await;
2352        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2353
2354        // Now search scoped to `scoped/` with a small top_k. The bug:
2355        // the 3 global top results all live in `noisy/`, so the path
2356        // filter rejects all of them and we get [].
2357        let scoped_options = SearchOptions {
2358            mode: SearchMode::Semantic,
2359            query: "database connection".to_string(),
2360            path: scoped.clone(),
2361            top_k: Some(3),
2362            threshold: Some(0.0),
2363            ..Default::default()
2364        };
2365
2366        let results = search(&scoped_options).await.unwrap();
2367
2368        assert!(
2369            !results.is_empty(),
2370            "Scoped search returned zero results — top_k was applied \
2371             before the path filter (the bug this test guards against)."
2372        );
2373        let all_in_scope = results.iter().all(|r| {
2374            r.file.starts_with(&scoped)
2375                || r.file.canonicalize().ok() == scoped.join("target.txt").canonicalize().ok()
2376        });
2377        assert!(
2378            all_in_scope,
2379            "Some results leaked out of the requested scope: {:?}",
2380            results.iter().map(|r| &r.file).collect::<Vec<_>>()
2381        );
2382    }
2383
2384    #[test]
2385    fn test_lenient_parse_matches_strict_for_valid_query() {
2386        // Invariance: a query that already parses cleanly yields the same
2387        // documents and scores whether parsed strictly or leniently.
2388        let mut schema_builder = Schema::builder();
2389        let content_field = schema_builder.add_text_field("content", TEXT | STORED);
2390        let schema = schema_builder.build();
2391        let index = Index::create_in_ram(schema);
2392        {
2393            let mut writer = index.writer(50_000_000).unwrap();
2394            writer
2395                .add_document(doc!(content_field => "zebra alpha"))
2396                .unwrap();
2397            writer
2398                .add_document(doc!(content_field => "zebra zebra beta"))
2399                .unwrap();
2400            writer
2401                .add_document(doc!(content_field => "gamma delta"))
2402                .unwrap();
2403            writer.commit().unwrap();
2404        }
2405        let searcher = index.reader().unwrap().searcher();
2406        let query_parser = QueryParser::for_index(&index, vec![content_field]);
2407
2408        let strict = query_parser.parse_query("zebra").unwrap();
2409        let (lenient, errors) = query_parser.parse_query_lenient("zebra");
2410        assert!(errors.is_empty(), "valid query must parse without errors");
2411
2412        let strict_hits = searcher.search(&strict, &TopDocs::with_limit(10)).unwrap();
2413        let lenient_hits = searcher.search(&lenient, &TopDocs::with_limit(10)).unwrap();
2414        assert_eq!(strict_hits.len(), lenient_hits.len());
2415        for ((s_score, s_addr), (l_score, l_addr)) in strict_hits.iter().zip(lenient_hits.iter()) {
2416            assert_eq!(s_addr, l_addr, "same documents in the same order");
2417            assert!((s_score - l_score).abs() < 1e-6, "same scores");
2418        }
2419    }
2420
2421    #[tokio::test]
2422    async fn test_lexical_search_recovers_from_unbalanced_quote() {
2423        // parse_query hard-errors on an unbalanced quote; lenient parsing
2424        // recovers the bare term and still matches.
2425        let temp_dir = TempDir::new().unwrap();
2426        fs::write(
2427            temp_dir.path().join("mod.py"),
2428            "def gamma():\n    zebra = 3\n",
2429        )
2430        .unwrap();
2431        fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2432
2433        let options = SearchOptions {
2434            mode: SearchMode::Lexical,
2435            query: "zebra\"".to_string(),
2436            path: temp_dir.path().to_path_buf(),
2437            recursive: true,
2438            ..Default::default()
2439        };
2440
2441        let results = lexical_search(&options)
2442            .await
2443            .expect("lenient parse must not error on an unbalanced quote");
2444        assert!(
2445            results
2446                .iter()
2447                .any(|r| r.file.file_name().unwrap() == "mod.py")
2448        );
2449    }
2450
2451    #[tokio::test]
2452    async fn test_lexical_search_recovers_term_from_field_colon() {
2453        // A clause referencing an unknown field is dropped; the bare term still
2454        // matches, where parse_query would have failed the whole query.
2455        let temp_dir = TempDir::new().unwrap();
2456        fs::write(
2457            temp_dir.path().join("mod.py"),
2458            "def gamma():\n    zebra = 3\n",
2459        )
2460        .unwrap();
2461        fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2462
2463        let options = SearchOptions {
2464            mode: SearchMode::Lexical,
2465            query: "zebra foo:baz".to_string(),
2466            path: temp_dir.path().to_path_buf(),
2467            recursive: true,
2468            ..Default::default()
2469        };
2470
2471        let results = lexical_search(&options)
2472            .await
2473            .expect("lenient parse must not error on a stray field colon");
2474        assert!(
2475            results
2476                .iter()
2477                .any(|r| r.file.file_name().unwrap() == "mod.py")
2478        );
2479    }
2480
2481    #[tokio::test]
2482    async fn test_lexical_search_all_fragments_error_degrades_gracefully() {
2483        // Every fragment references an unknown field, so nothing is
2484        // interpretable. parse_query would error; lenient parsing yields normal
2485        // results (here empty) instead.
2486        let temp_dir = TempDir::new().unwrap();
2487        fs::write(
2488            temp_dir.path().join("mod.py"),
2489            "def gamma():\n    zebra = 3\n",
2490        )
2491        .unwrap();
2492        fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2493
2494        let options = SearchOptions {
2495            mode: SearchMode::Lexical,
2496            query: "title:zebra".to_string(),
2497            path: temp_dir.path().to_path_buf(),
2498            recursive: true,
2499            ..Default::default()
2500        };
2501
2502        assert!(
2503            lexical_search(&options).await.is_ok(),
2504            "an all-unknown-field query must degrade gracefully, not error"
2505        );
2506    }
2507
2508    #[test]
2509    fn test_lexical_query_terms_tokenization() {
2510        assert_eq!(lexical_query_terms("Hello, World!"), vec!["hello", "world"]);
2511        assert_eq!(
2512            lexical_query_terms("snake_case-and.dots"),
2513            vec!["snake", "case", "and", "dots"]
2514        );
2515        assert!(lexical_query_terms("   ").is_empty());
2516        assert!(lexical_query_terms("").is_empty());
2517    }
2518
2519    #[test]
2520    fn test_locate_lexical_span_late_section() {
2521        // The query term lives in the third function, so its chunk span must
2522        // start well below line 1 and bracket the term's line.
2523        let content = "fn alpha() {\n    let x = 1;\n}\n\nfn beta() {\n    let y = 2;\n}\n\nfn gamma() {\n    let zebra = 3;\n}\n";
2524        let term_line = content.lines().position(|l| l.contains("zebra")).unwrap() + 1;
2525
2526        let (span, preview) = locate_lexical_span(
2527            Path::new("sample.rs"),
2528            content,
2529            &["zebra".to_string()],
2530            false,
2531        );
2532
2533        assert!(span.line_start > 1, "span should not start at line 1");
2534        assert!(
2535            span.line_start <= term_line && span.line_end >= term_line,
2536            "span {}-{} should bracket the term line {term_line}",
2537            span.line_start,
2538            span.line_end
2539        );
2540        assert!(preview.to_lowercase().contains("zebra"));
2541        // The preview is the chunk, not the whole file.
2542        assert!(!preview.contains("fn alpha"));
2543    }
2544
2545    #[test]
2546    fn test_locate_lexical_span_no_match_falls_back_to_whole_file() {
2547        // A term that no chunk contains yields the whole-file span + first-lines
2548        // preview rather than dropping provenance entirely.
2549        let content = "fn alpha() {\n    let x = 1;\n}\n\nfn beta() {\n    let y = 2;\n}\n";
2550        let (span, preview) = locate_lexical_span(
2551            Path::new("sample.rs"),
2552            content,
2553            &["nonexistentterm".to_string()],
2554            false,
2555        );
2556
2557        assert_eq!(span.line_start, 1);
2558        assert_eq!(span.line_end, content.lines().count());
2559        assert_eq!(span.byte_start, 0);
2560        assert_eq!(span.byte_end, content.len());
2561        assert_eq!(
2562            preview,
2563            content.lines().take(3).collect::<Vec<_>>().join("\n")
2564        );
2565    }
2566
2567    #[test]
2568    fn test_locate_lexical_span_empty_terms_falls_back() {
2569        // No query terms (e.g. a query that parsed to nothing on the content
2570        // field) yields the whole-file span rather than a spurious chunk.
2571        let content = "one\ntwo\nthree\n";
2572        let (span, _) = locate_lexical_span(Path::new("f.txt"), content, &[], false);
2573        assert_eq!(span.line_start, 1);
2574        assert_eq!(span.line_end, content.lines().count());
2575    }
2576
2577    #[test]
2578    fn test_locate_lexical_span_full_section_keeps_whole_file() {
2579        // full_section=true keeps whole-file content as the preview even though
2580        // the span narrows to the matching chunk.
2581        let content = "fn alpha() {\n    let x = 1;\n}\n\nfn gamma() {\n    let zebra = 3;\n}\n";
2582        let (span, preview) = locate_lexical_span(
2583            Path::new("sample.rs"),
2584            content,
2585            &["zebra".to_string()],
2586            true,
2587        );
2588
2589        assert!(span.line_start > 1);
2590        assert_eq!(preview, content);
2591    }
2592
2593    #[test]
2594    fn test_locate_lexical_span_counts_tokens_not_substrings() {
2595        // "in" occurs as a substring inside the first function's words
2596        // (printing, index, string) but as a real token only in the second.
2597        // Substring counting would score the first chunk 3 and pick it; whole-
2598        // token counting scores it 0 and correctly selects the second chunk.
2599        let content =
2600            "fn printing() {\n    let index = string;\n}\n\nfn other() {\n    let x = \"in\";\n}\n";
2601        let target_line = content.lines().position(|l| l.contains("\"in\"")).unwrap() + 1;
2602
2603        let (span, preview) =
2604            locate_lexical_span(Path::new("sample.rs"), content, &["in".to_string()], false);
2605
2606        assert!(
2607            span.line_start > 3,
2608            "token counting should select the second function, got line_start {}",
2609            span.line_start
2610        );
2611        assert!(span.line_start <= target_line && span.line_end >= target_line);
2612        assert!(preview.contains("\"in\""));
2613        assert!(!preview.contains("printing"));
2614    }
2615
2616    #[test]
2617    fn test_locate_lexical_span_last_chunk_reaches_eof() {
2618        // When the winning chunk is the last one in the file, its span reaches
2619        // the final line rather than stopping short.
2620        let content = "fn early() {\n    let a = 1;\n}\n\nfn late() {\n    let zebra = 2;\n}\n";
2621        let (span, _) =
2622            locate_lexical_span(Path::new("s.rs"), content, &["zebra".to_string()], false);
2623        assert!(span.line_start > 1);
2624        assert_eq!(
2625            span.line_end,
2626            content.lines().count(),
2627            "last chunk span should reach end of file"
2628        );
2629    }
2630
2631    #[tokio::test]
2632    async fn test_lexical_search_span_uses_parsed_query_terms() {
2633        // Regression: span selection must use the PARSED query's terms, not a
2634        // re-tokenization of the raw string. `content:zebra` matches "zebra" on
2635        // the content field, `index OR zebra` drops the operator, and a phrase
2636        // keeps only its leaf terms — but the raw string carries "content" and
2637        // "or", which flood the phantom-heavy first chunk and would point the
2638        // span at the wrong place.
2639        let temp_dir = TempDir::new().unwrap();
2640        let mut body = String::from("fn phantoms() {\n");
2641        for i in 0..30 {
2642            body.push_str(&format!("    let content_{i} = {i};\n"));
2643        }
2644        for i in 0..30 {
2645            body.push_str(&format!("    let or_{i} = {i};\n"));
2646        }
2647        body.push_str("}\n\nfn target() {\n    let index = 1;\n    let zebra = 2;\n    let pair = \"index zebra\";\n}\n");
2648        fs::write(temp_dir.path().join("f.rs"), &body).unwrap();
2649        fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2650        let zebra_line = body.lines().position(|l| l.contains("let zebra")).unwrap() + 1;
2651
2652        for query in ["content:zebra", "\"index zebra\"", "index OR zebra"] {
2653            let options = SearchOptions {
2654                mode: SearchMode::Lexical,
2655                query: query.to_string(),
2656                path: temp_dir.path().to_path_buf(),
2657                recursive: true,
2658                ..Default::default()
2659            };
2660            let results = lexical_search(&options).await.unwrap();
2661            let hit = results
2662                .iter()
2663                .find(|r| r.file.file_name().unwrap() == "f.rs")
2664                .unwrap_or_else(|| panic!("expected a hit for query {query:?}"));
2665            assert!(
2666                hit.span.line_start <= zebra_line && hit.span.line_end >= zebra_line,
2667                "query {query:?}: span {}-{} should bracket the real match at line {zebra_line}",
2668                hit.span.line_start,
2669                hit.span.line_end
2670            );
2671            assert!(
2672                hit.preview.to_lowercase().contains("zebra"),
2673                "query {query:?}: preview should contain the real match, got {:?}",
2674                hit.preview
2675            );
2676        }
2677    }
2678
2679    #[tokio::test]
2680    async fn test_lexical_search_reports_chunk_line_span() {
2681        let temp_dir = TempDir::new().unwrap();
2682        let file = temp_dir.path().join("late.rs");
2683        // The match sits in the final function; padding functions push it down.
2684        fs::write(
2685            &file,
2686            "fn alpha() {\n    let a = 1;\n}\n\nfn beta() {\n    let b = 2;\n}\n\nfn gamma() {\n    let zebra = 3;\n}\n",
2687        )
2688        .unwrap();
2689        // The index directory must exist; the tantivy subindex is then built
2690        // lazily on first lexical search, mirroring the CLI's `ck index` step.
2691        fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2692
2693        let options = SearchOptions {
2694            mode: SearchMode::Lexical,
2695            query: "zebra".to_string(),
2696            path: temp_dir.path().to_path_buf(),
2697            recursive: true,
2698            ..Default::default()
2699        };
2700
2701        let results = lexical_search(&options).await.unwrap();
2702        let hit = results
2703            .iter()
2704            .find(|r| r.file.file_name().unwrap() == "late.rs")
2705            .expect("expected a hit for late.rs");
2706
2707        assert!(
2708            hit.span.line_start > 1,
2709            "lexical hit should report a mid-file line, got line_start {}",
2710            hit.span.line_start
2711        );
2712        assert!(hit.preview.to_lowercase().contains("zebra"));
2713    }
2714
2715    #[tokio::test]
2716    async fn test_lexical_search_ranking_unchanged() {
2717        // Ranking-invariance guard: span/preview refinement must not perturb
2718        // which documents match, their order, or their scores.
2719        let temp_dir = TempDir::new().unwrap();
2720        fs::write(temp_dir.path().join("a.txt"), "zebra zebra zebra").unwrap();
2721        fs::write(temp_dir.path().join("b.txt"), "zebra tail").unwrap();
2722        fs::write(temp_dir.path().join("c.txt"), "nothing relevant here").unwrap();
2723        fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2724
2725        let options = SearchOptions {
2726            mode: SearchMode::Lexical,
2727            query: "zebra".to_string(),
2728            path: temp_dir.path().to_path_buf(),
2729            recursive: true,
2730            ..Default::default()
2731        };
2732
2733        let results = lexical_search(&options).await.unwrap();
2734        let files: Vec<String> = results
2735            .iter()
2736            .map(|r| r.file.file_name().unwrap().to_string_lossy().into_owned())
2737            .collect();
2738
2739        // Only the two documents containing the term match, ordered by score.
2740        assert_eq!(files, vec!["a.txt".to_string(), "b.txt".to_string()]);
2741        for pair in results.windows(2) {
2742            assert!(
2743                pair[0].score >= pair[1].score,
2744                "results must be in descending score order"
2745            );
2746        }
2747        // Top score is normalized to 1.0, exactly as before this patch.
2748        assert!((results[0].score - 1.0).abs() < 1e-6);
2749    }
2750}