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#[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
96pub(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 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 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
312pub(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
390pub(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 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 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}