Skip to main content

a3s_code_core/workspace/retrieval/
lexical.rs

1use super::catalog::ChunkCatalogSnapshot;
2use super::types::{
3    WorkspaceChunk, WorkspaceIndexError, WorkspaceIndexResult, WorkspaceLexicalEngine,
4};
5use crate::workspace::WorkspacePath;
6use rayon::prelude::*;
7use std::collections::{HashMap, HashSet};
8use std::mem::size_of;
9use std::sync::Arc;
10
11const DEFAULT_QUERY_TERM_LIMIT: usize = 32;
12const DEFAULT_CANDIDATE_FILE_LIMIT: usize = 256;
13const DEFAULT_RESULT_LIMIT: usize = 10;
14const MAX_RESULT_LIMIT: usize = 25;
15const MAX_QUERY_BYTES: usize = 2_048;
16const DEFAULT_RESULTS_PER_FILE: usize = 2;
17const PARALLEL_BUILD_MIN_DOCUMENTS: usize = 128;
18const PARALLEL_BUILD_MIN_BYTES: usize = 64 * 1024;
19
20pub(crate) fn should_parallelize_build(document_count: usize, text_bytes: usize) -> bool {
21    document_count >= PARALLEL_BUILD_MIN_DOCUMENTS
22        && text_bytes >= PARALLEL_BUILD_MIN_BYTES
23        && std::thread::available_parallelism()
24            .map(|workers| workers.get() > 1)
25            .unwrap_or(true)
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct LexicalSearchRequest {
30    pub query: String,
31    pub path: WorkspacePath,
32    pub glob: Option<String>,
33    pub limit: usize,
34    pub max_candidate_files: usize,
35    pub max_results_per_file: usize,
36}
37
38impl LexicalSearchRequest {
39    pub fn new(query: impl Into<String>) -> Self {
40        Self {
41            query: query.into(),
42            path: WorkspacePath::root(),
43            glob: None,
44            limit: DEFAULT_RESULT_LIMIT,
45            max_candidate_files: DEFAULT_CANDIDATE_FILE_LIMIT,
46            max_results_per_file: DEFAULT_RESULTS_PER_FILE,
47        }
48    }
49}
50
51#[derive(Clone, Debug)]
52pub struct LexicalSearchHit {
53    pub chunk: Arc<WorkspaceChunk>,
54    pub score: f64,
55}
56
57#[derive(Clone, Debug)]
58pub struct LexicalSearchResult {
59    pub catalog_revision: u64,
60    pub source_revision: u64,
61    pub lexical_engine: WorkspaceLexicalEngine,
62    pub query_terms: Vec<String>,
63    pub matching_files: usize,
64    pub selected_files: usize,
65    pub scored_chunks: usize,
66    pub candidate_truncated: bool,
67    pub hits: Vec<LexicalSearchHit>,
68}
69
70/// One dependency-free lexical document used by minimal builds.
71#[derive(Debug, Clone)]
72struct PortableDocument {
73    term_frequencies: HashMap<String, u32>,
74    length: usize,
75}
76
77impl PortableDocument {
78    fn from_tokens(tokens: &[String]) -> Self {
79        let mut term_frequencies = HashMap::new();
80        for token in tokens {
81            *term_frequencies.entry(token.clone()).or_insert(0) += 1;
82        }
83        Self {
84            term_frequencies,
85            length: tokens.len(),
86        }
87    }
88}
89
90#[derive(Debug, Clone)]
91struct PortablePosting {
92    document: usize,
93    term_frequency: u32,
94}
95
96/// Small, deterministic scorer used by minimal builds that intentionally omit
97/// the a3s-vec artifact. It shares the exact tokenizer and result contract
98/// with the a3s-vec path, so disabling engine feature never removes BM25.
99pub(crate) struct PortableLexicalIndex {
100    documents: Vec<PortableDocument>,
101    postings: HashMap<String, Vec<PortablePosting>>,
102    terms: HashSet<String>,
103    estimated_bytes: usize,
104}
105
106impl PortableLexicalIndex {
107    fn build(documents: &[(String, String)]) -> WorkspaceIndexResult<Self> {
108        let mut seen_keys = HashSet::new();
109        for (key, _) in documents {
110            if key.is_empty() || key.contains('\0') {
111                return Err(WorkspaceIndexError::InvalidConfig(
112                    "lexical document key must be non-empty and contain no NUL byte".to_owned(),
113                ));
114            }
115            if !seen_keys.insert(key) {
116                return Err(WorkspaceIndexError::InvalidConfig(
117                    "lexical document keys must be unique".to_owned(),
118                ));
119            }
120        }
121        // Tokenization and per-document term-frequency construction are pure
122        // CPU work. Rayon keeps the resulting vector in input order, which is
123        // required for deterministic BM25 tie-breaking and chunk ordinals.
124        // Tiny partitions stay serial because scheduling overhead would cost
125        // more than the work; large rebuilds use every available worker.
126        let parallel = should_parallelize_build(
127            documents.len(),
128            documents
129                .iter()
130                .fold(0usize, |total, (_, text)| total.saturating_add(text.len())),
131        );
132        let tokenized = if parallel {
133            documents
134                .par_iter()
135                .filter_map(|(_, text)| {
136                    let tokens = tokenize(text);
137                    (!tokens.is_empty()).then_some(tokens)
138                })
139                .collect::<Vec<_>>()
140        } else {
141            documents
142                .iter()
143                .filter_map(|(_, text)| {
144                    let tokens = tokenize(text);
145                    (!tokens.is_empty()).then_some(tokens)
146                })
147                .collect::<Vec<_>>()
148        };
149        let indexed = if parallel {
150            tokenized
151                .par_iter()
152                .map(|tokens| PortableDocument::from_tokens(tokens))
153                .collect::<Vec<_>>()
154        } else {
155            tokenized
156                .iter()
157                .map(|tokens| PortableDocument::from_tokens(tokens))
158                .collect::<Vec<_>>()
159        };
160        let terms = if parallel {
161            tokenized
162                .par_iter()
163                .flat_map_iter(|tokens| tokens.iter().cloned())
164                .collect::<HashSet<_>>()
165        } else {
166            tokenized
167                .iter()
168                .flat_map(|tokens| tokens.iter().cloned())
169                .collect::<HashSet<_>>()
170        };
171        let mut postings = if parallel {
172            indexed
173                .par_iter()
174                .enumerate()
175                .fold(
176                    HashMap::<String, Vec<PortablePosting>>::new,
177                    |mut postings, (document, stats)| {
178                        for (term, frequency) in &stats.term_frequencies {
179                            postings
180                                .entry(term.clone())
181                                .or_default()
182                                .push(PortablePosting {
183                                    document,
184                                    term_frequency: *frequency,
185                                });
186                        }
187                        postings
188                    },
189                )
190                .reduce(
191                    HashMap::<String, Vec<PortablePosting>>::new,
192                    |mut left, mut right| {
193                        for (term, mut values) in right.drain() {
194                            left.entry(term).or_default().append(&mut values);
195                        }
196                        left
197                    },
198                )
199        } else {
200            let mut postings = HashMap::<String, Vec<PortablePosting>>::new();
201            for (document, stats) in indexed.iter().enumerate() {
202                for (term, frequency) in &stats.term_frequencies {
203                    postings
204                        .entry(term.clone())
205                        .or_default()
206                        .push(PortablePosting {
207                            document,
208                            term_frequency: *frequency,
209                        });
210                }
211            }
212            postings
213        };
214        if parallel {
215            // Rayon reduction order is intentionally unspecified. Restore
216            // document order in each posting list before scoring so floating
217            // point accumulation and deterministic ties remain stable.
218            for values in postings.values_mut() {
219                values.sort_unstable_by_key(|posting| posting.document);
220            }
221        }
222        let estimated_bytes = size_of::<Self>()
223            .saturating_add(indexed.len().saturating_mul(size_of::<PortableDocument>()))
224            .saturating_add(
225                indexed
226                    .iter()
227                    .flat_map(|document| document.term_frequencies.keys())
228                    .map(|term| term.capacity())
229                    .sum::<usize>(),
230            )
231            .saturating_add(
232                postings
233                    .iter()
234                    .map(|(term, values)| {
235                        term.capacity().saturating_add(
236                            values.len().saturating_mul(size_of::<PortablePosting>()),
237                        )
238                    })
239                    .sum::<usize>(),
240            );
241        Ok(Self {
242            documents: indexed,
243            postings,
244            terms,
245            estimated_bytes,
246        })
247    }
248
249    fn document_count(&self) -> usize {
250        self.documents.len()
251    }
252
253    fn estimated_bytes(&self) -> usize {
254        self.estimated_bytes
255    }
256
257    fn has_any_term(&self, terms: &[String]) -> bool {
258        terms.iter().any(|term| self.terms.contains(term))
259    }
260
261    fn search(&self, terms: &[String], limit: usize) -> WorkspaceIndexResult<Vec<(usize, f64)>> {
262        if terms.is_empty() || limit == 0 || self.documents.is_empty() {
263            return Ok(Vec::new());
264        }
265        const K1: f64 = 1.2;
266        const B: f64 = 0.75;
267        let document_count = self.documents.len() as f64;
268        let average_length = (self
269            .documents
270            .iter()
271            .map(|document| document.length)
272            .sum::<usize>() as f64
273            / document_count)
274            .max(1.0);
275        let mut scores = vec![0.0; self.documents.len()];
276        let mut seen = HashSet::new();
277        for term in terms {
278            if !seen.insert(term.as_str()) {
279                continue;
280            }
281            let Some(postings) = self.postings.get(term) else {
282                continue;
283            };
284            let document_frequency = postings.len() as f64;
285            let idf = (1.0
286                + (document_count - document_frequency + 0.5) / (document_frequency + 0.5))
287                .ln();
288            for posting in postings {
289                let length_ratio = self.documents[posting.document].length as f64 / average_length;
290                let frequency = posting.term_frequency as f64;
291                let denominator = frequency + K1 * (1.0 - B + B * length_ratio);
292                scores[posting.document] +=
293                    idf * (frequency * (K1 + 1.0) / denominator.max(f64::EPSILON));
294            }
295        }
296        let mut hits = scores
297            .into_iter()
298            .enumerate()
299            .filter(|(_, score)| score.is_finite() && *score > 0.0)
300            .collect::<Vec<_>>();
301        hits.sort_by(|left, right| {
302            right
303                .1
304                .total_cmp(&left.1)
305                .then_with(|| left.0.cmp(&right.0))
306        });
307        hits.truncate(limit);
308        Ok(hits)
309    }
310}
311
312/// Backend-neutral lexical index handle. The a3s-vec binding is the
313/// product default; the portable variant is available only for minimal builds.
314pub(crate) enum LexicalIndex {
315    Portable(PortableLexicalIndex),
316    #[cfg(feature = "a3s-vec-fts")]
317    A3sVec(super::a3s_vec::A3sVecLexicalIndex),
318}
319
320impl LexicalIndex {
321    fn build(
322        documents: &[(String, String)],
323        engine: WorkspaceLexicalEngine,
324    ) -> WorkspaceIndexResult<Self> {
325        match engine {
326            WorkspaceLexicalEngine::Portable => {
327                PortableLexicalIndex::build(documents).map(Self::Portable)
328            }
329            WorkspaceLexicalEngine::A3sVec => {
330                #[cfg(feature = "a3s-vec-fts")]
331                {
332                    super::a3s_vec::A3sVecLexicalIndex::build(documents.to_vec())
333                        .map(Self::A3sVec)
334                        .map_err(|error| {
335                            WorkspaceIndexError::InvalidConfig(format!(
336                                "a3s-vec lexical index failed: {error}"
337                            ))
338                        })
339                }
340                #[cfg(not(feature = "a3s-vec-fts"))]
341                {
342                    Err(WorkspaceIndexError::InvalidConfig(
343                        "WorkspaceLexicalEngine::A3sVec requires the a3s-vec-fts feature"
344                            .to_owned(),
345                    ))
346                }
347            }
348        }
349    }
350
351    pub(crate) fn document_count(&self) -> usize {
352        match self {
353            Self::Portable(index) => index.document_count(),
354            #[cfg(feature = "a3s-vec-fts")]
355            Self::A3sVec(index) => index.document_count(),
356        }
357    }
358
359    pub(crate) fn estimated_bytes(&self) -> usize {
360        match self {
361            Self::Portable(index) => index.estimated_bytes(),
362            #[cfg(feature = "a3s-vec-fts")]
363            Self::A3sVec(index) => index.estimated_bytes(),
364        }
365    }
366
367    pub(crate) fn has_any_term(&self, terms: &[String]) -> bool {
368        match self {
369            Self::Portable(index) => index.has_any_term(terms),
370            #[cfg(feature = "a3s-vec-fts")]
371            Self::A3sVec(index) => index.has_any_term(terms),
372        }
373    }
374
375    pub(crate) fn search(
376        &self,
377        terms: &[String],
378        limit: usize,
379    ) -> WorkspaceIndexResult<Vec<(usize, f64)>> {
380        match self {
381            Self::Portable(index) => index.search(terms, limit),
382            #[cfg(feature = "a3s-vec-fts")]
383            Self::A3sVec(index) => index.search(terms, limit).map_err(|error| {
384                WorkspaceIndexError::InvalidQuery(format!("a3s-vec FTS search failed: {error}"))
385            }),
386        }
387    }
388}
389
390/// Build one bounded lexical index through the selected backend.
391///
392/// The query-time path and incremental workspace catalog intentionally call the
393/// same function, keeping backend selection in one place.
394pub(crate) fn build_lexical_index<I, K, T>(
395    documents: I,
396    engine: WorkspaceLexicalEngine,
397) -> WorkspaceIndexResult<LexicalIndex>
398where
399    I: IntoIterator<Item = (K, T)>,
400    K: AsRef<str>,
401    T: AsRef<str>,
402{
403    let documents = documents
404        .into_iter()
405        .map(|(key, text)| (key.as_ref().to_owned(), text.as_ref().to_owned()))
406        .collect::<Vec<_>>();
407    LexicalIndex::build(&documents, engine)
408}
409
410pub(crate) struct LexicalPartition {
411    index: LexicalIndex,
412    chunks: Arc<[Arc<WorkspaceChunk>]>,
413    pub(crate) document_count: usize,
414}
415
416impl LexicalPartition {
417    /// Build the catalog's lexical partition through the selected FTS API.
418    ///
419    /// Code still owns chunk admission and path policy, while tokenization,
420    /// postings, BM25 statistics, and deterministic score ordering are owned
421    /// by the selected engine. Workspace source remains authoritative and no
422    /// durable cross-session index is introduced by lexical search.
423    pub(crate) fn build(
424        chunks: Arc<[Arc<WorkspaceChunk>]>,
425        engine: WorkspaceLexicalEngine,
426    ) -> WorkspaceIndexResult<Self> {
427        let indexed_chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
428            chunks
429                .iter()
430                .filter(|chunk| !tokenize(chunk.text.as_ref()).is_empty())
431                .cloned()
432                .collect::<Vec<_>>(),
433        );
434        let index = build_lexical_index(
435            indexed_chunks
436                .iter()
437                .map(|chunk| (chunk.id.as_str(), chunk.text.as_ref())),
438            engine,
439        )?;
440        let document_count = index.document_count();
441        Ok(Self {
442            index,
443            chunks: indexed_chunks,
444            document_count,
445        })
446    }
447
448    fn has_any_term(&self, terms: &[String]) -> bool {
449        self.index.has_any_term(terms)
450    }
451
452    pub(crate) fn estimated_bytes(&self) -> usize {
453        self.index.estimated_bytes()
454    }
455
456    fn search(
457        &self,
458        terms: &[String],
459        limit: usize,
460    ) -> WorkspaceIndexResult<Vec<(Arc<WorkspaceChunk>, f64)>> {
461        if terms.is_empty() || limit == 0 {
462            return Ok(Vec::new());
463        }
464        self.index.search(terms, limit).map(|hits| {
465            hits.into_iter()
466                .filter_map(|(ordinal, score)| {
467                    self.chunks
468                        .get(ordinal)
469                        .cloned()
470                        .map(|chunk| (chunk, score))
471                })
472                .collect()
473        })
474    }
475}
476
477pub(crate) fn search_catalog(
478    snapshot: &ChunkCatalogSnapshot,
479    request: &LexicalSearchRequest,
480) -> Result<LexicalSearchResult, WorkspaceIndexError> {
481    validate_request(request)?;
482    let terms = query_terms(request.query.trim(), DEFAULT_QUERY_TERM_LIMIT);
483    if terms.is_empty() {
484        return Err(WorkspaceIndexError::InvalidQuery(
485            "query must contain a letter, number, underscore, or CJK character".to_owned(),
486        ));
487    }
488    let glob = request
489        .glob
490        .as_deref()
491        .map(glob::Pattern::new)
492        .transpose()
493        .map_err(|error| WorkspaceIndexError::InvalidQuery(error.to_string()))?;
494
495    let matching = snapshot
496        .state
497        .files
498        .iter()
499        .filter(|(path, file)| {
500            path_matches(path, &request.path, glob.as_ref()) && file.lexical.has_any_term(&terms)
501        })
502        .collect::<Vec<_>>();
503    let matching_files = matching.len();
504    let candidate_truncated = matching_files > request.max_candidate_files;
505    let selected = matching
506        .into_iter()
507        .take(request.max_candidate_files)
508        .collect::<Vec<_>>();
509    let selected_files = selected.len();
510    let document_count = selected
511        .iter()
512        .map(|(_, file)| file.lexical.document_count)
513        .sum::<usize>();
514    if document_count == 0 {
515        return Ok(LexicalSearchResult {
516            catalog_revision: snapshot.revision(),
517            source_revision: snapshot.source_revision(),
518            lexical_engine: snapshot.lexical_engine(),
519            query_terms: terms,
520            matching_files,
521            selected_files: selected.len(),
522            scored_chunks: 0,
523            candidate_truncated,
524            hits: Vec::new(),
525        });
526    }
527    let mut ranked = Vec::new();
528    // The final contract admits at most `max_results_per_file` hits from any
529    // one file. Asking each backend for more candidates only adds native FTS
530    // work (and result materialization) without changing the observable
531    // ordering.
532    let per_file_limit = request.limit.min(request.max_results_per_file);
533    for (_, file) in selected {
534        for (chunk, score) in file.lexical.search(&terms, per_file_limit)? {
535            if score.is_finite() && score > 0.0 {
536                ranked.push(LexicalSearchHit { chunk, score });
537            }
538        }
539    }
540    ranked.sort_by(|left, right| {
541        right
542            .score
543            .total_cmp(&left.score)
544            .then_with(|| left.chunk.path.cmp(&right.chunk.path))
545            .then_with(|| left.chunk.start_byte.cmp(&right.chunk.start_byte))
546            .then_with(|| left.chunk.id.cmp(&right.chunk.id))
547    });
548
549    let mut per_file = HashMap::<Arc<str>, usize>::new();
550    let hits = ranked
551        .into_iter()
552        .filter(|hit| {
553            let count = per_file.entry(Arc::clone(&hit.chunk.path)).or_default();
554            if *count >= request.max_results_per_file {
555                return false;
556            }
557            *count += 1;
558            true
559        })
560        .take(request.limit)
561        .collect();
562
563    Ok(LexicalSearchResult {
564        catalog_revision: snapshot.revision(),
565        source_revision: snapshot.source_revision(),
566        lexical_engine: snapshot.lexical_engine(),
567        query_terms: terms,
568        matching_files,
569        selected_files,
570        scored_chunks: document_count,
571        candidate_truncated,
572        hits,
573    })
574}
575
576pub(crate) fn validate_request(request: &LexicalSearchRequest) -> Result<(), WorkspaceIndexError> {
577    if request.query.trim().is_empty() {
578        return Err(WorkspaceIndexError::InvalidQuery(
579            "query must not be empty".to_owned(),
580        ));
581    }
582    if request.query.len() > MAX_QUERY_BYTES {
583        return Err(WorkspaceIndexError::InvalidQuery(format!(
584            "query exceeds the {MAX_QUERY_BYTES}-byte limit"
585        )));
586    }
587    if request.limit == 0 || request.limit > MAX_RESULT_LIMIT {
588        return Err(WorkspaceIndexError::InvalidQuery(format!(
589            "limit must be from 1 to {MAX_RESULT_LIMIT}"
590        )));
591    }
592    if request.max_candidate_files == 0 || request.max_results_per_file == 0 {
593        return Err(WorkspaceIndexError::InvalidQuery(
594            "candidate and per-file limits must be greater than zero".to_owned(),
595        ));
596    }
597    Ok(())
598}
599
600pub(crate) fn path_matches(path: &str, base: &WorkspacePath, glob: Option<&glob::Pattern>) -> bool {
601    let relative = if base.is_root() {
602        path
603    } else if path == base.as_str() {
604        path.rsplit('/').next().unwrap_or(path)
605    } else {
606        let Some(relative) = path
607            .strip_prefix(base.as_str())
608            .and_then(|path| path.strip_prefix('/'))
609        else {
610            return false;
611        };
612        relative
613    };
614    glob.is_none_or(|pattern| pattern.matches(relative) || pattern.matches(path))
615}
616
617pub(crate) fn query_terms(query: &str, limit: usize) -> Vec<String> {
618    let mut seen = HashSet::new();
619    tokenize(query)
620        .into_iter()
621        .filter(|term| seen.insert(term.clone()))
622        .take(limit)
623        .collect()
624}
625
626pub(crate) fn tokenize(text: &str) -> Vec<String> {
627    let mut tokens = Vec::new();
628    let mut word = String::new();
629    let mut previous_cjk = None;
630
631    for ch in text.chars() {
632        if is_cjk(ch) {
633            flush_word(&mut word, &mut tokens);
634            tokens.push(ch.to_string());
635            if let Some(previous) = previous_cjk {
636                tokens.push(format!("{previous}{ch}"));
637            }
638            previous_cjk = Some(ch);
639        } else {
640            previous_cjk = None;
641            if ch.is_alphanumeric() || ch == '_' {
642                word.push(ch);
643            } else {
644                flush_word(&mut word, &mut tokens);
645            }
646        }
647    }
648    flush_word(&mut word, &mut tokens);
649    tokens
650}
651
652fn flush_word(word: &mut String, tokens: &mut Vec<String>) {
653    if word.is_empty() {
654        return;
655    }
656    if !word.chars().any(char::is_alphanumeric) {
657        word.clear();
658        return;
659    }
660    let mut variants = vec![word.to_lowercase()];
661    for segment in word.split('_').filter(|segment| !segment.is_empty()) {
662        variants.push(segment.to_lowercase());
663        variants.extend(split_identifier(segment));
664    }
665    let mut seen = HashSet::new();
666    tokens.extend(
667        variants
668            .into_iter()
669            .filter(|variant| !variant.is_empty() && seen.insert(variant.clone())),
670    );
671    word.clear();
672}
673
674fn split_identifier(identifier: &str) -> Vec<String> {
675    let chars = identifier.chars().collect::<Vec<_>>();
676    if chars.is_empty() {
677        return Vec::new();
678    }
679    let mut parts = Vec::new();
680    let mut start = 0usize;
681    for index in 1..chars.len() {
682        let previous = chars[index - 1];
683        let current = chars[index];
684        let next = chars.get(index + 1).copied();
685        let at_case_boundary = previous.is_lowercase() && current.is_uppercase();
686        let at_acronym_boundary = previous.is_uppercase()
687            && current.is_uppercase()
688            && next.is_some_and(char::is_lowercase);
689        let at_numeric_boundary = previous.is_numeric() != current.is_numeric()
690            && previous.is_alphanumeric()
691            && current.is_alphanumeric();
692        if at_case_boundary || at_acronym_boundary || at_numeric_boundary {
693            parts.push(
694                chars[start..index]
695                    .iter()
696                    .collect::<String>()
697                    .to_lowercase(),
698            );
699            start = index;
700        }
701    }
702    parts.push(chars[start..].iter().collect::<String>().to_lowercase());
703    parts
704}
705
706fn is_cjk(ch: char) -> bool {
707    matches!(
708        ch as u32,
709        0x3400..=0x4dbf
710            | 0x4e00..=0x9fff
711            | 0xf900..=0xfaff
712            | 0x20000..=0x2fa1f
713            | 0x3040..=0x30ff
714            | 0xac00..=0xd7af
715    )
716}
717
718#[cfg(test)]
719mod tests {
720    use super::{
721        path_matches, validate_request, LexicalSearchRequest, PortableLexicalIndex, MAX_QUERY_BYTES,
722    };
723
724    #[test]
725    fn large_portable_build_keeps_dense_ordinals_after_empty_documents() {
726        let documents = (0..256)
727            .map(|index| {
728                let text = if index % 13 == 0 {
729                    " \n\t".to_owned()
730                } else {
731                    format!(
732                        "{} {}",
733                        if index == 129 {
734                            "parallelneedle"
735                        } else {
736                            "common"
737                        },
738                        "workspace payload ".repeat(64)
739                    )
740                };
741                (format!("doc-{index:03}"), text)
742            })
743            .collect::<Vec<_>>();
744        let index = PortableLexicalIndex::build(&documents).expect("portable index");
745        let hits = index
746            .search(&["parallelneedle".to_owned()], 1)
747            .expect("portable query");
748        let expected_ordinal = (0..129).filter(|index| index % 13 != 0).count();
749        assert_eq!(hits.first().map(|hit| hit.0), Some(expected_ordinal));
750        assert_eq!(index.document_count(), 236);
751    }
752
753    #[test]
754    fn portable_build_rejects_empty_and_duplicate_keys() {
755        let empty = PortableLexicalIndex::build(&[("".to_owned(), "text".to_owned())]);
756        assert!(
757            matches!(
758                empty,
759                Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidConfig(_))
760            ),
761            "empty key should be InvalidConfig"
762        );
763
764        let dup = PortableLexicalIndex::build(&[
765            ("same".to_owned(), "a".to_owned()),
766            ("same".to_owned(), "b".to_owned()),
767        ]);
768        assert!(
769            matches!(
770                dup,
771                Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidConfig(_))
772            ),
773            "duplicate keys should be InvalidConfig"
774        );
775    }
776
777    #[test]
778    fn portable_search_returns_empty_for_empty_terms_or_zero_limit() {
779        let index = PortableLexicalIndex::build(&[("doc".to_owned(), "alpha beta".to_owned())])
780            .expect("index");
781        assert!(index.search(&[], 8).expect("empty terms").is_empty());
782        assert!(index
783            .search(&["alpha".to_owned()], 0)
784            .expect("zero limit")
785            .is_empty());
786    }
787
788    #[test]
789    fn portable_build_rejects_nul_in_document_key() {
790        let err = PortableLexicalIndex::build(&[("bad\0key".to_owned(), "text".to_owned())]);
791        assert!(matches!(
792            err,
793            Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidConfig(_))
794        ));
795    }
796
797    #[test]
798    fn validate_request_rejects_empty_oversized_and_zero_limits() {
799        let mut request = LexicalSearchRequest::new("   ");
800        assert!(matches!(
801            validate_request(&request),
802            Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidQuery(_))
803        ));
804
805        request = LexicalSearchRequest::new("a".repeat(MAX_QUERY_BYTES + 1));
806        assert!(matches!(
807            validate_request(&request),
808            Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidQuery(_))
809        ));
810
811        request = LexicalSearchRequest::new("alpha");
812        request.limit = 0;
813        assert!(matches!(
814            validate_request(&request),
815            Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidQuery(_))
816        ));
817
818        request = LexicalSearchRequest::new("alpha");
819        request.max_candidate_files = 0;
820        assert!(matches!(
821            validate_request(&request),
822            Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidQuery(_))
823        ));
824
825        request = LexicalSearchRequest::new("alpha");
826        request.max_results_per_file = 0;
827        assert!(matches!(
828            validate_request(&request),
829            Err(crate::workspace::retrieval::WorkspaceIndexError::InvalidQuery(_))
830        ));
831
832        request = LexicalSearchRequest::new("!!! ???");
833        assert!(validate_request(&request).is_ok());
834    }
835
836    #[test]
837    fn path_matches_handles_root_base_exact_and_prefix_paths() {
838        use crate::workspace::WorkspacePath;
839        let root = WorkspacePath::root();
840        assert!(path_matches("src/main.rs", &root, None));
841
842        let base = WorkspacePath::from_normalized("src");
843        assert!(path_matches("src", &base, None));
844        assert!(path_matches("src/lib.rs", &base, None));
845        assert!(!path_matches("tests/lib.rs", &base, None));
846
847        let pattern = glob::Pattern::new("*.rs").expect("glob");
848        assert!(path_matches("src/lib.rs", &base, Some(&pattern)));
849        assert!(!path_matches("src/lib.toml", &base, Some(&pattern)));
850    }
851}