1pub mod anchored_lane;
2pub mod blocks;
3pub mod comparator;
4pub mod confidence;
5pub mod evidence_descriptor;
6pub mod exact_lane;
7pub mod extensions;
8pub mod generation_token;
9pub mod lexical_lane;
10pub mod memo;
11pub mod paging;
12pub mod plan_table;
13pub mod provenance;
14pub mod scoring;
15pub mod telemetry;
16pub mod trailer;
17
18pub use comparator::{r3_cmp, score_free_r3_cmp, CandidateResult, RankedTuple, SymbolOffsetRange};
19pub use evidence_descriptor::{
20 compute_evidence_descriptor, CandidateEvidenceProvider, EvidenceDescriptor, EvidenceKind,
21 EvidenceTier,
22};
23pub use generation_token::GenerationToken;
24pub use plan_table::{
25 verify_pinned_plan_table_at_startup, LanePlanEntry, PlanTable, PlanTableError, SearchLaneKind,
26 SearchShape, PINNED_PLAN_TABLE_JSON,
27};
28
29pub struct LaneInput<'a> {
31 pub query: &'a str,
32 pub shape: SearchShape,
33 pub root: &'a Path,
34 pub include_tests: bool,
35 pub index: &'a SearchIndex,
36}
37
38#[derive(Debug, Clone)]
39pub struct LaneExecution {
40 pub kind: SearchLaneKind,
41 pub candidates: Vec<CandidateResult>,
42}
43
44pub trait SearchLane: Send + Sync {
46 fn kind(&self) -> SearchLaneKind;
47 fn plan_order_index(&self) -> usize {
48 self.kind().default_plan_order_index()
49 }
50 fn execute(&self, _input: &LaneInput<'_>) -> LaneExecution {
51 LaneExecution {
52 kind: self.kind(),
53 candidates: Vec::new(),
54 }
55 }
56}
57
58#[derive(Default)]
60pub struct LaneRegistry {
61 lanes: HashMap<SearchLaneKind, Arc<dyn SearchLane>>,
62}
63
64impl LaneRegistry {
65 pub fn new() -> Self {
66 Self {
67 lanes: HashMap::new(),
68 }
69 }
70
71 pub fn register(&mut self, lane: Arc<dyn SearchLane>) {
72 self.lanes.insert(lane.kind(), lane);
73 }
74
75 pub fn get(&self, kind: SearchLaneKind) -> Option<&Arc<dyn SearchLane>> {
76 self.lanes.get(&kind)
77 }
78
79 pub fn registered_kinds(&self) -> Vec<SearchLaneKind> {
80 let mut kinds: Vec<_> = self.lanes.keys().copied().collect();
81 kinds.sort_by_key(|k| k.default_plan_order_index());
82 kinds
83 }
84}
85
86pub fn register_lane(registry: &mut LaneRegistry, lane: Arc<dyn SearchLane>) {
87 registry.register(lane);
88}
89
90use std::borrow::Cow;
91use std::collections::{HashMap, HashSet};
92use std::fs;
93use std::io::BufRead;
94use std::path::{Path, PathBuf};
95use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
96use std::sync::{Arc, OnceLock};
97use std::time::{Duration, Instant};
98
99use rayon::prelude::*;
100use rusqlite::{Connection, OpenFlags, OptionalExtension};
101use serde::Deserialize;
102
103use crate::commands::callgraph_store_adapter::callers_result;
104use crate::commands::symbol_render::{
105 build_container_outline, might_have_container_members, render_symbol_within_budget,
106 BudgetedSymbolRenderStatus,
107};
108use crate::config::IndexKind;
109use crate::context::{AppContext, SemanticIndexStatus};
110use crate::grep_executor::{self, GrepParams};
111use crate::inspect::job::{is_test_file, is_test_support_file};
112use crate::list_envelope::ListEnvelope;
113use crate::pattern_compile::{self, CompileOpts, CompileResult};
114use crate::protocol::{RawRequest, Response};
115use crate::query_shape::{self, QueryKind, QueryShape};
116use crate::readonly_artifacts::{GitRootResolutionError, ReadOnlyArtifact, ReadOnlyDegradation};
117use crate::search_index::{
118 sort_grep_matches_by_mtime_desc, try_read_with_budget, walk_project_files_from, GrepMatch,
119 GrepPathExclusion, GrepResult, IndexStatus, PathFilters, SearchIndex,
120 INTERACTIVE_ARTIFACT_READ_BUDGET,
121};
122use crate::semantic_index::{
123 query_embedding_is_busy, query_embedding_timeout_budget, strip_query_embedding_busy_marker,
124 strip_query_embedding_timeout_marker, EmbeddingModel, QueryBudget, SemanticIndex,
125 SemanticIndexFingerprint, SemanticResult,
126};
127use crate::symbols::{Range, Symbol, SymbolKind};
128
129const DEFAULT_TOP_K: usize = 10;
130pub const SEMANTIC_ENUMERATION_LIMIT: usize = 100;
133const DEGRADED_GREP_FILE_LIMIT: usize = 1_000;
134const DEGRADED_GREP_RESULT_LIMIT: usize = 100;
135const DEGRADED_GREP_WALK_BUDGET: Duration = Duration::from_secs(10);
136const FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET: Duration = Duration::from_millis(2_500);
140const SEARCH_INDEX_LOAD_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(2);
141const SUPPRESS_STATUS_BAR_FIELD: &str = "_aft_suppress_status_bar";
142const TRIGRAM_BUILDING_BOUNDED_WALK_DISCLOSURE: &str =
143 "trigram index building; results from a bounded walk";
144
145#[cfg(test)]
146thread_local! {
147 static FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET_OVERRIDE: std::cell::Cell<Option<Duration>> =
148 const { std::cell::Cell::new(None) };
149}
150
151fn first_search_index_load_wait_budget() -> Duration {
152 #[cfg(test)]
153 if let Some(budget) = FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET_OVERRIDE.with(std::cell::Cell::get) {
154 return budget;
155 }
156 FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET
157}
158
159#[cfg(test)]
160fn with_first_search_index_load_wait_budget_for_test<T>(
161 budget: Duration,
162 action: impl FnOnce() -> T,
163) -> T {
164 struct Reset(Option<Duration>);
165 impl Drop for Reset {
166 fn drop(&mut self) {
167 FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET_OVERRIDE.with(|slot| slot.set(self.0));
168 }
169 }
170
171 let previous =
172 FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET_OVERRIDE.with(|slot| slot.replace(Some(budget)));
173 let _reset = Reset(previous);
174 action()
175}
176const BORROWED_SEARCH_LOAD_WARNING: &str = "Borrowed search index loading stopped at the interactive budget; returning a bounded lexical scan (no semantic ranking).";
177const BORROWED_SEARCH_LOAD_FOOTER: &str = "[Degraded: borrowed search index loading stopped at the interactive budget; bounded lexical scan only.]";
178const BORROWED_SEMANTIC_LOADING_WITH_LEXICAL_RESULTS: &str = "Semantic lane is loading the shared index; lexical results below are complete for exact/identifier matches.";
179const STALE_CLI_SNAPSHOT_WARNING: &str = "Serving the last usable standing-root CLI snapshot after its freshness could not be verified; rerun `npx @cortexkit/aft index` to refresh it.";
180const RANK0_FULL_SNIPPET_MAX_LINES: usize = 250;
186
187const RANK0_FULL_SYMBOL_NOTICE: &str =
194 "full symbol shown as-is from disk; edit directly from this context — no need to re-read or zoom first";
195
196#[derive(Debug, Clone)]
197pub struct HybridResult {
198 pub file: PathBuf,
199 pub name: String,
200 pub kind: SymbolKind,
201 pub start_line: u32,
202 pub end_line: u32,
203 pub exported: bool,
204 pub score: f32,
205 pub source: &'static str,
206 pub semantic_score: Option<f32>,
207 pub lexical_score: Option<f32>,
208 pub hybrid_boosted: bool,
209 pub exact: bool,
210 pub(crate) exact_phrase_count: usize,
211 pub(crate) exact_window_lines: Option<usize>,
212 pub(crate) fusion_score: f32,
213 pub snippet: String,
214}
215
216#[derive(Debug, Deserialize)]
217struct SemanticSearchParams {
218 query: String,
219 #[serde(default = "default_top_k", alias = "topK")]
220 top_k: usize,
221 #[serde(default)]
222 offset: usize,
223 #[serde(default, alias = "includeTests")]
224 include_tests: bool,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228enum SearchMode {
229 Regex,
230 Literal,
231 Semantic,
232 Hybrid,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum SearchIndexWaitError {
237 Cancelled,
238 Contended,
239}
240
241struct RuntimeReadinessSource<'a> {
242 ctx: &'a AppContext,
243}
244
245#[derive(Clone)]
246struct ExternalBorrowedArtifacts {
247 search: ReadOnlyArtifact<Arc<SearchIndex>>,
248 semantic: ReadOnlyArtifact<Arc<SemanticIndex>>,
249 search_generation: GenerationToken,
250}
251
252struct ExternalReadinessSource<'a> {
253 ctx: &'a AppContext,
254 root: &'a Path,
255 storage_dir: Option<&'a Path>,
256 loaded: OnceLock<ExternalBorrowedArtifacts>,
257}
258
259impl<'a> ExternalReadinessSource<'a> {
260 fn new(ctx: &'a AppContext, root: &'a Path, storage_dir: Option<&'a Path>) -> Self {
261 Self {
262 ctx,
263 root,
264 storage_dir,
265 loaded: OnceLock::new(),
266 }
267 }
268
269 fn load(&self) -> &ExternalBorrowedArtifacts {
270 self.loaded.get_or_init(|| {
271 let generation = crate::readonly_artifacts::search_index_artifact_generation(
272 self.root,
273 self.storage_dir,
274 )
275 .map(|artifact| format!("{:?}", artifact.generation))
276 .unwrap_or_else(|| "absent".to_string());
277 ExternalBorrowedArtifacts {
278 search: self
279 .ctx
280 .open_borrowed_search_index(self.root, self.storage_dir),
281 semantic: self
282 .ctx
283 .open_borrowed_semantic_index(self.root, self.storage_dir),
284 search_generation: GenerationToken::new_with_str(&format!(
285 "borrowed:{}:{generation}",
286 self.root.display()
287 )),
288 }
289 })
290 }
291
292 fn loaded(&self) -> Option<&ExternalBorrowedArtifacts> {
293 self.loaded.get()
294 }
295}
296
297impl extensions::ReadinessSource for ExternalReadinessSource<'_> {
298 fn sample(&self) -> extensions::ReadinessObservation<'_> {
299 use extensions::{
300 ReadinessObservation, SemanticReadiness, SemanticSnapshot, SymbolIndexStatus,
301 SymbolReadiness, TrigramReadiness,
302 };
303
304 let Some(artifacts) = self.loaded() else {
305 return ReadinessObservation {
306 semantic: SemanticReadiness {
307 status: SemanticIndexStatus::Building {
308 stage: "loading_artifacts".to_string(),
309 files: None,
310 entries_done: None,
311 entries_total: None,
312 },
313 snapshot: None,
314 evicted: false,
315 lock_contended: false,
316 },
317 trigram: TrigramReadiness {
318 status: IndexStatus::Building,
319 snapshot: None,
320 evicted: false,
321 lock_contended: false,
322 },
323 symbol: SymbolReadiness {
324 status: SymbolIndexStatus::Disabled,
325 snapshot: None,
326 evicted: false,
327 lock_contended: false,
328 },
329 };
330 };
331
332 let trigram = match &artifacts.search {
333 ReadOnlyArtifact::Fresh(index)
334 | ReadOnlyArtifact::Stale(crate::readonly_artifacts::ReadOnlyStale { index, .. }) => {
335 TrigramReadiness {
336 status: IndexStatus::Ready,
337 snapshot: Some(Arc::new(index.snapshot())),
338 evicted: false,
339 lock_contended: false,
340 }
341 }
342 ReadOnlyArtifact::Degraded(_) | ReadOnlyArtifact::Absent => TrigramReadiness {
343 status: IndexStatus::Fallback,
344 snapshot: None,
345 evicted: false,
346 lock_contended: false,
347 },
348 ReadOnlyArtifact::Cancelled => TrigramReadiness {
349 status: IndexStatus::Building,
350 snapshot: None,
351 evicted: false,
352 lock_contended: false,
353 },
354 };
355 let semantic =
356 match &artifacts.semantic {
357 ReadOnlyArtifact::Fresh(index)
358 | ReadOnlyArtifact::Stale(crate::readonly_artifacts::ReadOnlyStale {
359 index, ..
360 }) if semantic_fingerprint_matches_session(self.ctx, index) => SemanticReadiness {
361 status: SemanticIndexStatus::ready(),
362 snapshot: Some(SemanticSnapshot::from_borrowed(Arc::clone(index))),
363 evicted: false,
364 lock_contended: false,
365 },
366 ReadOnlyArtifact::Cancelled => SemanticReadiness {
367 status: SemanticIndexStatus::Building {
368 stage: "loading_artifacts".to_string(),
369 files: None,
370 entries_done: None,
371 entries_total: None,
372 },
373 snapshot: None,
374 evicted: false,
375 lock_contended: false,
376 },
377 ReadOnlyArtifact::Degraded(_) => SemanticReadiness {
378 status: SemanticIndexStatus::Failed(
379 "borrowed_semantic_index_load_budget".to_string(),
380 ),
381 snapshot: None,
382 evicted: false,
383 lock_contended: false,
384 },
385 ReadOnlyArtifact::Fresh(_) | ReadOnlyArtifact::Stale(_) => SemanticReadiness {
386 status: SemanticIndexStatus::Failed("fingerprint_mismatch".to_string()),
387 snapshot: None,
388 evicted: false,
389 lock_contended: false,
390 },
391 ReadOnlyArtifact::Absent => SemanticReadiness {
392 status: SemanticIndexStatus::Disabled,
393 snapshot: None,
394 evicted: false,
395 lock_contended: false,
396 },
397 };
398
399 ReadinessObservation {
400 semantic,
401 trigram,
402 symbol: SymbolReadiness {
403 status: SymbolIndexStatus::Disabled,
404 snapshot: None,
405 evicted: false,
406 lock_contended: false,
407 },
408 }
409 }
410
411 fn bounded_first_search_wait(&self) -> extensions::ReadinessWait {
412 let artifacts = self.load();
413 if matches!(artifacts.search, ReadOnlyArtifact::Cancelled)
414 || matches!(artifacts.semantic, ReadOnlyArtifact::Cancelled)
415 {
416 extensions::ReadinessWait::Cancelled
417 } else {
418 extensions::ReadinessWait::Completed
419 }
420 }
421}
422
423impl extensions::ReadinessSource for RuntimeReadinessSource<'_> {
424 fn sample(&self) -> extensions::ReadinessObservation<'_> {
425 use extensions::{
426 ReadinessObservation, SemanticReadiness, SemanticSnapshot, SymbolIndexStatus,
427 SymbolReadiness, TrigramReadiness,
428 };
429
430 let semantic = match try_read_with_budget(
431 self.ctx.semantic_index_status(),
432 INTERACTIVE_ARTIFACT_READ_BUDGET,
433 ) {
434 Some(status) => {
435 let status = status.clone();
436 if matches!(status, SemanticIndexStatus::Ready { .. }) {
437 match try_read_with_budget(
438 self.ctx.semantic_index(),
439 INTERACTIVE_ARTIFACT_READ_BUDGET,
440 ) {
441 Some(index) => {
442 let evicted = index.is_none();
443 let snapshot = (!evicted).then(|| SemanticSnapshot::from_guard(index));
444 SemanticReadiness {
445 evicted,
446 status,
447 snapshot,
448 lock_contended: false,
449 }
450 }
451 None => SemanticReadiness {
452 status,
453 snapshot: None,
454 evicted: false,
455 lock_contended: true,
456 },
457 }
458 } else {
459 SemanticReadiness {
460 status,
461 snapshot: None,
462 evicted: false,
463 lock_contended: false,
464 }
465 }
466 }
467 None => SemanticReadiness {
468 status: SemanticIndexStatus::Building {
469 stage: "status_lock".to_string(),
470 files: None,
471 entries_done: None,
472 entries_total: None,
473 },
474 snapshot: None,
475 evicted: false,
476 lock_contended: true,
477 },
478 };
479
480 let trigram =
481 match try_read_with_budget(self.ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) {
482 Some(index) if index.as_ref().is_some_and(SearchIndex::is_ready) => {
483 TrigramReadiness {
484 status: IndexStatus::Ready,
485 snapshot: index.as_ref().map(SearchIndex::snapshot).map(Arc::new),
486 evicted: false,
487 lock_contended: false,
488 }
489 }
490 Some(index) if index.is_some() => TrigramReadiness {
491 status: IndexStatus::Building,
492 snapshot: None,
493 evicted: false,
494 lock_contended: false,
495 },
496 Some(_) => {
497 let receiver = try_read_with_budget(
498 self.ctx.search_index_rx(),
499 INTERACTIVE_ARTIFACT_READ_BUDGET,
500 );
501 match receiver {
502 Some(receiver) if receiver.is_some() => TrigramReadiness {
503 status: IndexStatus::Building,
504 snapshot: None,
505 evicted: false,
506 lock_contended: false,
507 },
508 Some(_) => TrigramReadiness {
509 status: IndexStatus::Fallback,
510 snapshot: None,
511 evicted: false,
512 lock_contended: false,
513 },
514 None => TrigramReadiness {
515 status: IndexStatus::Building,
516 snapshot: None,
517 evicted: false,
518 lock_contended: true,
519 },
520 }
521 }
522 None => TrigramReadiness {
523 status: IndexStatus::Building,
524 snapshot: None,
525 evicted: false,
526 lock_contended: true,
527 },
528 };
529
530 let symbol_cache = self.ctx.symbol_cache();
531 let symbol = match try_read_with_budget(&symbol_cache, INTERACTIVE_ARTIFACT_READ_BUDGET) {
532 Some(cache) if cache.len() > 0 => SymbolReadiness {
533 status: SymbolIndexStatus::Ready,
534 snapshot: Some(Arc::new(cache.clone())),
535 evicted: false,
536 lock_contended: false,
537 },
538 Some(_) => SymbolReadiness {
539 status: SymbolIndexStatus::Building,
540 snapshot: None,
541 evicted: true,
542 lock_contended: false,
543 },
544 None => SymbolReadiness {
545 status: SymbolIndexStatus::Building,
546 snapshot: None,
547 evicted: false,
548 lock_contended: true,
549 },
550 };
551
552 ReadinessObservation {
553 semantic,
554 trigram,
555 symbol,
556 }
557 }
558
559 fn bounded_first_search_wait(&self) -> extensions::ReadinessWait {
560 if matches!(
563 self.ctx.search_index().try_read(),
564 Err(std::sync::TryLockError::WouldBlock)
565 ) {
566 return extensions::ReadinessWait::Completed;
567 }
568 match search_index_ready_with_budget(self.ctx, first_search_index_load_wait_budget()) {
569 Err(SearchIndexWaitError::Cancelled) => extensions::ReadinessWait::Cancelled,
570 Ok(_) | Err(SearchIndexWaitError::Contended) => extensions::ReadinessWait::Completed,
571 }
572 }
573}
574
575#[derive(Debug, Clone)]
576struct DegradedGrepFallbackResult {
577 grep: GrepResult,
578 file_cap_reached: bool,
579 file_limit: usize,
580 candidate_files: usize,
581 walk_budget_reached: bool,
582}
583
584#[derive(Debug, Clone, Default)]
585struct ExternalBorrowMetadata {
586 drift_count: usize,
587 ignore_rules_differ: bool,
588 degraded_reason: Option<&'static str>,
589 standing_snapshot: bool,
590 strict_verification_required: bool,
591}
592
593impl ExternalBorrowMetadata {
594 fn record_drift(&mut self, drift_count: usize, ignore_rules_differ: bool) {
595 self.drift_count = self.drift_count.max(drift_count);
596 self.ignore_rules_differ |= ignore_rules_differ;
597 }
598
599 fn stale_cli_snapshot(&self) -> bool {
600 self.standing_snapshot && (self.strict_verification_required || self.drift_count > 0)
601 }
602}
603
604fn standing_snapshot_metadata(
605 external_root: &Path,
606 storage_dir: Option<&Path>,
607) -> ExternalBorrowMetadata {
608 let db_path = crate::bash_background::storage_dir(storage_dir).join("aft.db");
609 if !db_path.is_file() {
610 return ExternalBorrowMetadata::default();
611 }
612
613 let resolved_target = std::fs::canonicalize(external_root)
614 .unwrap_or_else(|_| external_root.to_path_buf())
615 .display()
616 .to_string();
617 let Ok(conn) = crate::db::open(&db_path) else {
618 return ExternalBorrowMetadata::default();
619 };
620 let Ok(needs_strict_verify) =
621 crate::db::standing_roots::needs_strict_verify_for_resolved_target(
622 &conn,
623 &resolved_target,
624 IndexKind::Search,
625 )
626 else {
627 return ExternalBorrowMetadata::default();
628 };
629
630 let Some(strict_verification_required) = needs_strict_verify else {
631 return ExternalBorrowMetadata::default();
632 };
633 ExternalBorrowMetadata {
634 standing_snapshot: true,
635 strict_verification_required,
636 ..ExternalBorrowMetadata::default()
637 }
638}
639
640pub(crate) fn search_cancellation_requested() -> bool {
641 crate::executor::current_job_cancelled()
642}
643
644fn cancelled_search_response(req: &RawRequest) -> Response {
645 cancelled_search_response_from_id(&req.id)
646}
647
648fn cancelled_search_response_from_id(request_id: &str) -> Response {
649 Response::error(
650 request_id,
651 "request_cancelled",
652 "Search request cancelled because its route closed.",
653 )
654}
655
656pub fn handle_semantic_search(req: &RawRequest, ctx: &AppContext) -> Response {
657 use extensions::{RawQuery, Root, Token};
658
659 let page_request = match paging::parse_public_page_request(&req.params) {
660 Ok(request) => request,
661 Err(error) => return Response::error(&req.id, error.code(), error.to_string()),
662 };
663 let raw_query = RawQuery::new(
664 req.params
665 .get("query")
666 .and_then(|value| value.as_str())
667 .unwrap_or_default(),
668 );
669 if raw_query.original_query().trim().is_empty() {
670 return Response::error(&req.id, "invalid_request", "query must be non-empty");
671 }
672 let project_root = grep_executor::project_root(ctx);
673 let requested_path = req
674 .params
675 .get("path")
676 .and_then(|value| value.as_str())
677 .map(str::trim)
678 .filter(|path| !path.is_empty());
679 let external_root = if let Some(requested_path) = requested_path {
680 match ctx.resolve_external_git_root(&project_root, requested_path) {
681 Ok(root) if root != project_root => {
682 if ctx.config().restrict_to_project_root || ctx.request_force_restrict(&req.id) {
683 return Response::error(
684 &req.id,
685 "path_outside_root",
686 format!(
687 "aft_search path is outside the configured project root while path restriction is enabled: {}",
688 root.display()
689 ),
690 );
691 }
692 Some(root)
693 }
694 Ok(_) => None,
695 Err(GitRootResolutionError::PathNotFound(path)) => {
696 return Response::error(
697 &req.id,
698 "path_not_found",
699 format!("path does not exist: {}", path.display()),
700 );
701 }
702 Err(GitRootResolutionError::NotAGitRoot) => {
703 return Response::error(
704 &req.id,
705 "not_a_git_root",
706 format!("path is not inside a git repository: {requested_path}"),
707 );
708 }
709 Err(GitRootResolutionError::Other(error)) => {
710 return Response::error(&req.id, "path_resolution_failed", error);
711 }
712 }
713 } else {
714 None
715 };
716
717 let extensions = crate::search_b2::install_defaults();
720 let (shape, facts) = extensions.classify(&raw_query);
721 let variants = extensions.variants(Token {
722 index: 0,
723 text: raw_query.original_query(),
724 });
725 let runtime_source = RuntimeReadinessSource { ctx };
726 let storage_dir = ctx.config().storage_dir.clone();
727 let external_source = external_root
728 .as_deref()
729 .map(|root| ExternalReadinessSource::new(ctx, root, storage_dir.as_deref()));
730 let readiness_source: &dyn extensions::ReadinessSource = external_source
731 .as_ref()
732 .map(|source| source as &dyn extensions::ReadinessSource)
733 .unwrap_or(&runtime_source);
734 let root = Root::new(
735 external_root.as_deref().unwrap_or(&project_root),
736 readiness_source,
737 );
738 let readiness = extensions.sample_readiness(&root);
739 if readiness.cancelled() {
740 return cancelled_search_response(req);
741 }
742 let mut plan = extensions.plan(&shape, &facts, &readiness);
743 if plan.contains(SearchLaneKind::Exact) {
744 plan.exact_input = Some(crate::search_b2::router::exact_input(
745 &raw_query, shape, &facts,
746 ));
747 }
748 if plan.contains(SearchLaneKind::Variants) {
749 plan.variants = variants.into_iter().map(|variant| variant.text).collect();
750 }
751
752 let _embedding_attribution = crate::search_b2::embed_counter::install(req.id.clone());
753 let mut response = handle_semantic_search_inner(
754 req,
755 ctx,
756 page_request,
757 extensions,
758 &plan,
759 external_source.as_ref(),
760 );
761 if response.success {
762 let embedding_counts = crate::search_b2::embed_counter::read(&req.id);
763 attach_search_execution_metadata(&mut response, &plan, embedding_counts);
764 }
765 response
766}
767
768fn attach_search_execution_metadata(
769 response: &mut Response,
770 plan: &extensions::LanePlan<'_>,
771 embedding_counts: crate::search_b2::embed_counter::EmbedCounts,
772) {
773 let Some(data) = response.data.as_object_mut() else {
774 return;
775 };
776 let structured = data
777 .entry("structuredContent".to_string())
778 .or_insert_with(|| serde_json::json!({}));
779 let Some(structured) = structured.as_object_mut() else {
780 return;
781 };
782 let extension_plan = serde_json::to_value(plan).unwrap_or_else(|_| serde_json::json!({}));
783 let structured_plan = structured
784 .entry("plan".to_string())
785 .or_insert_with(|| extension_plan.clone());
786 if let (Some(structured_plan), Some(extension_plan)) =
787 (structured_plan.as_object_mut(), extension_plan.as_object())
788 {
789 for (key, value) in extension_plan {
790 structured_plan
791 .entry(key.clone())
792 .or_insert_with(|| value.clone());
793 }
794 structured_plan.insert(
795 "embedding_calls".to_string(),
796 serde_json::json!(embedding_counts.requested),
797 );
798 structured_plan.insert(
799 "embedding_cache_hits".to_string(),
800 serde_json::json!(embedding_counts.cache_hits),
801 );
802 structured_plan.insert(
803 "live_embed_calls".to_string(),
804 serde_json::json!(embedding_counts.live_calls),
805 );
806 }
807 structured.insert(
808 "search".to_string(),
809 serde_json::json!({
810 "embedding_calls": embedding_counts.requested,
811 "embedding_cache_hits": embedding_counts.cache_hits,
812 "live_embed_calls": embedding_counts.live_calls,
813 }),
814 );
815}
816
817fn handle_semantic_search_inner(
818 req: &RawRequest,
819 ctx: &AppContext,
820 page_request: paging::ValidatedPageRequest,
821 extensions: &dyn extensions::SearchExtensions,
822 engine_plan: &extensions::LanePlan<'_>,
823 external_source: Option<&ExternalReadinessSource<'_>>,
824) -> Response {
825 if search_cancellation_requested() {
826 return cancelled_search_response(req);
827 }
828 let mut params = match serde_json::from_value::<SemanticSearchParams>(req.params.clone()) {
829 Ok(params) => params,
830 Err(error) => {
831 return Response::error(
832 &req.id,
833 "invalid_request",
834 format!("semantic_search: invalid params: {error}"),
835 );
836 }
837 };
838
839 if params.query.trim().is_empty() {
840 return Response::error(&req.id, "invalid_request", "query must be non-empty");
841 }
842
843 if engine_plan.shape == SearchShape::CodeLiteral {
846 params.query = strip_surrounding_quotes(params.query);
847 if params.query.trim().is_empty() {
848 return Response::error(&req.id, "invalid_request", "query must be non-empty");
849 }
850 }
851
852 let top_k = page_request.top_k();
853 params.top_k = top_k;
854 params.offset = page_request.offset();
855 let project_root = grep_executor::project_root(ctx);
856 let shape = query_shape::classify(¶ms.query);
857 if let Some(external_source) = external_source {
858 return handle_external_search(
859 req,
860 ctx,
861 params,
862 shape,
863 page_request,
864 extensions,
865 engine_plan,
866 external_source,
867 );
868 }
869 let semantic_status_snapshot = match try_read_with_budget(
870 ctx.semantic_index_status(),
871 INTERACTIVE_ARTIFACT_READ_BUDGET,
872 ) {
873 Some(status) => status.clone(),
874 None => {
875 return artifact_contention_fallback_response(
876 req,
877 ctx,
878 ¶ms,
879 &shape,
880 &project_root,
881 top_k,
882 "semantic index status remained busy",
883 );
884 }
885 };
886 let semantic_status = semantic_status_label(&semantic_status_snapshot);
887 let mut warnings = Vec::new();
888
889 let lexical_ready = match search_index_ready_with_budget(ctx, INTERACTIVE_ARTIFACT_READ_BUDGET)
890 {
891 Ok(ready) => ready,
892 Err(SearchIndexWaitError::Cancelled) => return cancelled_search_response(req),
893 Err(SearchIndexWaitError::Contended) => {
894 return artifact_contention_fallback_response(
895 req,
896 ctx,
897 ¶ms,
898 &shape,
899 &project_root,
900 top_k,
901 "search index remained busy",
902 );
903 }
904 };
905 let mode = choose_mode(¶ms.query, &shape, lexical_ready, &mut warnings);
906 if lexical_ready && mode != SearchMode::Regex && !engine_plan.contains(SearchLaneKind::Semantic)
907 {
908 return handle_engine_only_search(
909 req,
910 ctx,
911 ¶ms,
912 &shape,
913 semantic_status,
914 warnings,
915 &project_root,
916 page_request,
917 extensions,
918 engine_plan,
919 );
920 }
921
922 match mode {
923 SearchMode::Regex | SearchMode::Literal => handle_grep_search(
924 req,
925 ctx,
926 ¶ms.query,
927 params.offset,
928 top_k,
929 &shape,
930 mode,
931 semantic_status,
932 warnings,
933 &project_root,
934 params.include_tests,
935 page_request,
936 extensions,
937 engine_plan,
938 ),
939 SearchMode::Semantic | SearchMode::Hybrid => handle_semantic_or_hybrid_search(
940 req,
941 ctx,
942 params,
943 top_k,
944 shape,
945 mode,
946 lexical_ready,
947 semantic_status_snapshot,
948 semantic_status,
949 warnings,
950 &project_root,
951 page_request,
952 extensions,
953 engine_plan,
954 ),
955 }
956}
957
958fn handle_external_search(
959 req: &RawRequest,
960 ctx: &AppContext,
961 params: SemanticSearchParams,
962 shape: QueryShape,
963 page_request: paging::ValidatedPageRequest,
964 extensions: &dyn extensions::SearchExtensions,
965 engine_plan: &extensions::LanePlan<'_>,
966 readiness_source: &ExternalReadinessSource<'_>,
967) -> Response {
968 let top_k = page_request.top_k();
969 let external_root = readiness_source.root.to_path_buf();
970 let artifacts = readiness_source
971 .loaded()
972 .expect("external readiness always loads artifacts before execution");
973 let mut borrow_metadata = if ctx.daemonless_query_mode() {
974 standing_snapshot_metadata(&external_root, readiness_source.storage_dir)
975 } else {
976 ExternalBorrowMetadata::default()
977 };
978 let mut warnings = Vec::new();
979 let search_index = match &artifacts.search {
980 ReadOnlyArtifact::Fresh(index) => Arc::clone(index),
981 ReadOnlyArtifact::Degraded(degradation) => {
982 if engine_plan.contains(SearchLaneKind::Semantic) {
983 borrow_metadata.degraded_reason = Some(degradation.reason);
984 warnings.push(
985 "Borrowed trigram index loading stopped at the interactive budget; continuing with the semantic lane.".to_string(),
986 );
987 Arc::new(SearchIndex::new())
988 } else {
989 return handle_external_borrowed_degraded_fallback(
990 req,
991 ctx,
992 ¶ms,
993 top_k,
994 &shape,
995 &external_root,
996 *degradation,
997 );
998 }
999 }
1000 ReadOnlyArtifact::Cancelled => return cancelled_search_response(req),
1001 ReadOnlyArtifact::Absent => {
1002 if engine_plan.contains(SearchLaneKind::Semantic) {
1003 warnings.push(
1004 "External trigram index is not available; continuing with the semantic lane."
1005 .to_string(),
1006 );
1007 Arc::new(SearchIndex::new())
1008 } else {
1009 return handle_external_unindexed_fallback(
1010 req,
1011 ctx,
1012 ¶ms,
1013 top_k,
1014 &shape,
1015 &external_root,
1016 );
1017 }
1018 }
1019 ReadOnlyArtifact::Stale(stale) => {
1020 borrow_metadata.record_drift(stale.drift_count, stale.ignore_rules_differ);
1021 crate::slog_warn!(
1022 "{}",
1023 borrowed_drift_log_message("search", &external_root, stale.drift_count)
1024 );
1025 Arc::clone(&stale.index)
1026 }
1027 };
1028 if let ReadOnlyArtifact::Stale(stale) = &artifacts.semantic {
1029 borrow_metadata.record_drift(stale.drift_count, stale.ignore_rules_differ);
1030 crate::slog_warn!(
1031 "{}",
1032 borrowed_drift_log_message("semantic", &external_root, stale.drift_count)
1033 );
1034 }
1035 if borrow_metadata.standing_snapshot {
1036 borrow_metadata.record_drift(search_index.borrowed_stat_mismatch_count(), false);
1037 }
1038
1039 if borrow_metadata.stale_cli_snapshot() {
1040 warnings.push(STALE_CLI_SNAPSHOT_WARNING.to_string());
1041 }
1042 let mode = choose_mode(
1043 ¶ms.query,
1044 &shape,
1045 engine_plan.readiness.lexical_index,
1046 &mut warnings,
1047 );
1048
1049 match mode {
1050 SearchMode::Regex | SearchMode::Literal => handle_external_grep_search(
1051 req,
1052 ctx,
1053 ¶ms.query,
1054 page_request,
1055 &shape,
1056 mode,
1057 warnings,
1058 &external_root,
1059 params.include_tests,
1060 &search_index,
1061 &borrow_metadata,
1062 extensions,
1063 engine_plan,
1064 &artifacts.search_generation,
1065 ),
1066 SearchMode::Semantic | SearchMode::Hybrid => handle_external_semantic_or_hybrid_search(
1067 req,
1068 ctx,
1069 params,
1070 shape,
1071 mode,
1072 warnings,
1073 external_root,
1074 &search_index,
1075 &artifacts.semantic,
1076 &artifacts.search_generation,
1077 borrow_metadata,
1078 page_request,
1079 extensions,
1080 engine_plan,
1081 ),
1082 }
1083}
1084
1085fn handle_external_unindexed_fallback(
1092 req: &RawRequest,
1093 ctx: &AppContext,
1094 params: &SemanticSearchParams,
1095 top_k: usize,
1096 shape: &QueryShape,
1097 external_root: &Path,
1098) -> Response {
1099 handle_external_bounded_lexical_fallback(req, ctx, params, top_k, shape, external_root, None)
1100}
1101
1102fn handle_external_borrowed_degraded_fallback(
1103 req: &RawRequest,
1104 ctx: &AppContext,
1105 params: &SemanticSearchParams,
1106 top_k: usize,
1107 shape: &QueryShape,
1108 external_root: &Path,
1109 degradation: ReadOnlyDegradation,
1110) -> Response {
1111 handle_external_bounded_lexical_fallback(
1112 req,
1113 ctx,
1114 params,
1115 top_k,
1116 shape,
1117 external_root,
1118 Some(degradation),
1119 )
1120}
1121
1122fn handle_external_bounded_lexical_fallback(
1123 req: &RawRequest,
1124 ctx: &AppContext,
1125 params: &SemanticSearchParams,
1126 top_k: usize,
1127 shape: &QueryShape,
1128 external_root: &Path,
1129 degradation: Option<ReadOnlyDegradation>,
1130) -> Response {
1131 let borrow_metadata = ExternalBorrowMetadata::default();
1132 let literal = true;
1133 let compiled = match pattern_compile::compile(
1134 ¶ms.query,
1135 CompileOpts {
1136 literal,
1137 ..CompileOpts::default()
1138 },
1139 ) {
1140 CompileResult::Ok(compiled) => compiled,
1141 CompileResult::InvalidPattern { message, .. } => {
1142 return Response::error_with_data(
1143 &req.id,
1144 "invalid_pattern",
1145 message,
1146 external_response_extras(external_root, &borrow_metadata),
1147 );
1148 }
1149 CompileResult::UnsupportedSyntax { feature, .. } => {
1150 return Response::error_with_data(
1151 &req.id,
1152 "unsupported_pattern",
1153 format!(
1154 "Pattern uses regex syntax not supported by AFT's engine: {feature}. Rewrite without {feature} or use grep for explicit regex control."
1155 ),
1156 external_response_extras(external_root, &borrow_metadata),
1157 );
1158 }
1159 };
1160
1161 let path_value = serde_json::Value::String(external_root.to_string_lossy().into_owned());
1162 let scope = match grep_executor::resolve_grep_scope(ctx, Some(&path_value), top_k, &req.id) {
1163 Ok(scope) => scope,
1164 Err(response) => return response,
1165 };
1166 let grep_params = grep_executor::GrepParams {
1167 include: Vec::new(),
1168 exclude: Vec::new(),
1169 max_results: top_k,
1170 path_exclusion: grep_path_exclusion(params.include_tests),
1171 };
1172 let result = grep_executor::execute(ctx, &compiled, &scope, &grep_params);
1173 if search_cancellation_requested() {
1174 return cancelled_search_response(req);
1175 }
1176
1177 let result_source = if literal { "literal" } else { "regex" };
1178 let interpreted_as = if literal { "literal" } else { "regex" };
1179 let result_values = result
1180 .matches
1181 .iter()
1182 .map(|grep_match| grep_match_to_json(grep_match, result_source))
1183 .collect::<Vec<_>>();
1184 let display_root = absolute_display_root(external_root);
1185 let mut text = format_grep_search_text(&result, &display_root, interpreted_as);
1186 if degradation.is_some() {
1187 text.push_str("\n\n");
1188 text.push_str(BORROWED_SEARCH_LOAD_FOOTER);
1189 }
1190
1191 let mut warnings = Vec::new();
1192 if degradation.is_some() {
1193 warnings.push(BORROWED_SEARCH_LOAD_WARNING.to_string());
1194 } else {
1195 warnings.push(format!(
1196 "No AFT index exists for {} — returning a bounded lexical scan (no semantic ranking). Open a session in that project for full indexed search.",
1197 external_root.display()
1198 ));
1199 }
1200 if result.walk_truncated {
1201 warnings.push(
1202 "Lexical scan stopped early (file-count or time budget reached); results may be incomplete.".to_string(),
1203 );
1204 }
1205
1206 let more_available =
1207 result.walk_truncated || result.truncated || result.total_matches > result.matches.len();
1208 let mut extras = external_response_extras(external_root, &borrow_metadata)
1209 .as_object()
1210 .cloned()
1211 .unwrap_or_default();
1212 let envelope =
1213 bounded_walk_search_envelope(result_values.len(), more_available, result.engine_capped);
1214 crate::list_surfaces::search::attach_projected_search_envelope(&mut extras, &envelope);
1215 if let Some(degradation) = degradation {
1216 extras.insert(
1217 "borrowed_index_degraded_reason".to_string(),
1218 serde_json::json!(degradation.reason),
1219 );
1220 }
1221 search_response(
1222 req,
1223 SearchResponseParts {
1224 query: ¶ms.query,
1225 interpreted_as,
1226 query_kind: query_kind_label(shape.kind),
1227 semantic_status: if degradation.is_some() {
1228 "external_borrowed_degraded"
1229 } else {
1230 "external_unindexed"
1231 },
1232 status: "ready",
1233 complete: degradation.is_none() && !result.walk_truncated,
1234 text,
1235 results: result_values,
1236 more_available,
1237 engine_capped: result.engine_capped,
1238 fully_degraded: true,
1239 warnings,
1240 extras,
1241 },
1242 )
1243}
1244
1245fn handle_external_grep_search(
1246 req: &RawRequest,
1247 ctx: &AppContext,
1248 query: &str,
1249 page_request: paging::ValidatedPageRequest,
1250 shape: &QueryShape,
1251 mode: SearchMode,
1252 mut warnings: Vec<String>,
1253 external_root: &Path,
1254 include_tests: bool,
1255 search_index: &SearchIndex,
1256 borrow_metadata: &ExternalBorrowMetadata,
1257 extensions: &dyn extensions::SearchExtensions,
1258 engine_plan: &extensions::LanePlan<'_>,
1259 search_generation: &GenerationToken,
1260) -> Response {
1261 let top_k = page_request.top_k();
1262 let auto_regex = mode == SearchMode::Regex;
1263 let mut effective_mode = mode;
1264 let compile_literal_fallback = || -> Result<_, Response> {
1265 match pattern_compile::compile(
1266 query,
1267 CompileOpts {
1268 literal: true,
1269 ..CompileOpts::default()
1270 },
1271 ) {
1272 CompileResult::Ok(compiled) => Ok(compiled),
1273 CompileResult::InvalidPattern { message, .. } => Err(Response::error_with_data(
1274 &req.id,
1275 "invalid_pattern",
1276 message,
1277 external_response_extras(external_root, borrow_metadata),
1278 )),
1279 CompileResult::UnsupportedSyntax { feature, .. } => Err(Response::error_with_data(
1280 &req.id,
1281 "unsupported_pattern",
1282 format!(
1283 "Pattern uses regex syntax not supported by AFT's engine: {feature}. Rewrite without {feature} or use grep for explicit regex control."
1284 ),
1285 external_response_extras(external_root, borrow_metadata),
1286 )),
1287 }
1288 };
1289
1290 let compiled = match pattern_compile::compile(
1291 query,
1292 CompileOpts {
1293 literal: mode == SearchMode::Literal,
1294 ..CompileOpts::default()
1295 },
1296 ) {
1297 CompileResult::Ok(compiled) => compiled,
1298 CompileResult::InvalidPattern { message, .. } => {
1299 if auto_regex {
1300 warnings.push(auto_regex_literal_fallback_warning(
1301 short_regex_compile_reason(&message),
1302 ));
1303 effective_mode = SearchMode::Literal;
1304 match compile_literal_fallback() {
1305 Ok(compiled) => compiled,
1306 Err(response) => return response,
1307 }
1308 } else {
1309 return Response::error_with_data(
1310 &req.id,
1311 "invalid_pattern",
1312 message,
1313 external_response_extras(external_root, borrow_metadata),
1314 );
1315 }
1316 }
1317 CompileResult::UnsupportedSyntax { feature, .. } => {
1318 if auto_regex {
1319 warnings.push(auto_regex_literal_fallback_warning(format!(
1320 "{feature} is not supported"
1321 )));
1322 effective_mode = SearchMode::Literal;
1323 match compile_literal_fallback() {
1324 Ok(compiled) => compiled,
1325 Err(response) => return response,
1326 }
1327 } else {
1328 return Response::error_with_data(
1329 &req.id,
1330 "unsupported_pattern",
1331 format!(
1332 "Pattern uses regex syntax not supported by AFT's engine: {feature}. Rewrite without {feature} or use grep for explicit regex control."
1333 ),
1334 external_response_extras(external_root, borrow_metadata),
1335 );
1336 }
1337 }
1338 };
1339
1340 let literal = effective_mode == SearchMode::Literal;
1341 let fetch_limit = page_request.offset().saturating_add(top_k);
1342 let mut result = search_index.snapshot().search_grep_bounded(
1343 &compiled,
1344 &[],
1345 &[],
1346 external_root,
1347 fetch_limit,
1348 grep_path_exclusion(include_tests),
1349 grep_executor::MAX_FALLBACK_WALK_FILES,
1350 grep_executor::FALLBACK_WALK_BUDGET,
1351 );
1352 result
1353 .matches
1354 .retain(|grep_match| grep_match.file.is_file());
1355
1356 if result.matches.is_empty() {
1357 let extras = external_response_extras(external_root, borrow_metadata)
1358 .as_object()
1359 .cloned()
1360 .unwrap_or_default();
1361 return stale_cli_snapshot_partial_response(
1362 zero_result_escalation_response(
1363 req,
1364 ctx,
1365 query,
1366 shape,
1367 effective_mode,
1368 "external",
1369 warnings,
1370 include_tests,
1371 external_root,
1372 &absolute_display_root(external_root),
1373 extras,
1374 page_request,
1375 extensions,
1376 engine_plan,
1377 Some((search_index, search_generation)),
1378 ),
1379 borrow_metadata.stale_cli_snapshot(),
1380 );
1381 }
1382
1383 let interval_end = page_request.offset().saturating_add(top_k);
1384 let interval_has_more = result.total_matches > interval_end || result.truncated;
1385 result.matches = result
1386 .matches
1387 .into_iter()
1388 .skip(page_request.offset())
1389 .take(top_k)
1390 .collect();
1391 let result_source = if literal { "literal" } else { "regex" };
1392 let result_values = result
1393 .matches
1394 .iter()
1395 .map(|grep_match| grep_match_to_json(grep_match, result_source))
1396 .collect::<Vec<_>>();
1397 let interpreted_as = interpreted_as_label(effective_mode);
1398 let display_root = absolute_display_root(external_root);
1399 let text = format_grep_search_text(&result, &display_root, interpreted_as);
1400 let mut extras = external_response_extras(external_root, borrow_metadata)
1401 .as_object()
1402 .cloned()
1403 .unwrap_or_default();
1404 if let Some(envelope) =
1405 search_cut_envelope(result_values.len(), interval_has_more, result.engine_capped)
1406 {
1407 crate::list_surfaces::search::attach_projected_search_envelope(&mut extras, &envelope);
1408 }
1409 search_response(
1410 req,
1411 SearchResponseParts {
1412 query,
1413 interpreted_as,
1414 query_kind: query_kind_label(shape.kind),
1415 semantic_status: "external",
1416 status: "ready",
1417 complete: !borrow_metadata.stale_cli_snapshot(),
1418 text,
1419 results: result_values,
1420 more_available: interval_has_more,
1421 engine_capped: result.engine_capped,
1422 fully_degraded: false,
1423 warnings,
1424 extras,
1425 },
1426 )
1427}
1428
1429fn handle_external_semantic_or_hybrid_search(
1430 req: &RawRequest,
1431 ctx: &AppContext,
1432 params: SemanticSearchParams,
1433 shape: QueryShape,
1434 mode: SearchMode,
1435 mut warnings: Vec<String>,
1436 external_root: PathBuf,
1437 search_index: &SearchIndex,
1438 semantic_artifact: &ReadOnlyArtifact<Arc<SemanticIndex>>,
1439 search_generation: &GenerationToken,
1440 mut borrow_metadata: ExternalBorrowMetadata,
1441 page_request: paging::ValidatedPageRequest,
1442 extensions: &dyn extensions::SearchExtensions,
1443 engine_plan: &extensions::LanePlan<'_>,
1444) -> Response {
1445 let mut semantic_status = "ready";
1446 let semantic_index = match semantic_artifact {
1447 ReadOnlyArtifact::Fresh(index)
1448 | ReadOnlyArtifact::Stale(crate::readonly_artifacts::ReadOnlyStale { index, .. })
1449 if semantic_fingerprint_matches_session(ctx, index) =>
1450 {
1451 Some(index.as_ref())
1452 }
1453 ReadOnlyArtifact::Fresh(_) | ReadOnlyArtifact::Stale(_) => {
1454 semantic_status = "unavailable";
1455 warnings.push(
1456 "External semantic index was built for a different embedding backend or model; returning lexical-only results from the trigram index.".to_string(),
1457 );
1458 None
1459 }
1460 ReadOnlyArtifact::Degraded(degradation) => {
1461 semantic_status = "building";
1462 warnings.push(
1463 "Borrowed semantic index loading stopped at the interactive budget; returning lexical-only results from the trigram index.".to_string(),
1464 );
1465 borrow_metadata.degraded_reason = Some(degradation.reason);
1466 None
1467 }
1468 ReadOnlyArtifact::Cancelled => return cancelled_search_response(req),
1469 ReadOnlyArtifact::Absent => {
1470 semantic_status = "unavailable";
1471 warnings.push(
1472 "External semantic index is not available; returning lexical-only results from the trigram index.".to_string(),
1473 );
1474 None
1475 }
1476 };
1477
1478 let mut semantic_more_available = false;
1479 let mut semantic_results = if engine_plan.contains(SearchLaneKind::Semantic) {
1480 semantic_index
1481 .map(|semantic_index| {
1482 embed_query_for_dimension(¶ms.query, ctx, Some(semantic_index.dimension())).map(
1483 |query_vector| {
1484 let mut results = semantic_index.search_filtered(
1485 &query_vector,
1486 SEMANTIC_ENUMERATION_LIMIT.saturating_add(1),
1487 |file| {
1488 path_allowed_by_include_tests(
1489 file,
1490 &external_root,
1491 params.include_tests,
1492 )
1493 },
1494 );
1495 results.retain(|result| result.file.is_file());
1496 semantic_more_available = results.len() > SEMANTIC_ENUMERATION_LIMIT;
1497 if semantic_more_available {
1498 results.truncate(SEMANTIC_ENUMERATION_LIMIT);
1499 }
1500 rerank_semantic_candidates(&mut results, &shape, ¶ms.query);
1501 results
1502 },
1503 )
1504 })
1505 .transpose()
1506 .unwrap_or_else(|error| {
1507 semantic_status = "unavailable";
1508 warnings.push(classify_embed_query_error(&error).detail);
1509 None
1510 })
1511 .unwrap_or_default()
1512 } else {
1513 Vec::new()
1514 };
1515
1516 let mut ranked = match run_engine_ranking(
1517 &req.id,
1518 ctx,
1519 &external_root,
1520 ¶ms.query,
1521 params.include_tests,
1522 std::mem::take(&mut semantic_results),
1523 page_request,
1524 extensions,
1525 engine_plan,
1526 Some((search_index, search_generation)),
1527 ) {
1528 Ok(ranked) => ranked,
1529 Err(error) => return Response::error(&req.id, "search_engine_failed", error),
1530 };
1531 ranked.results.retain(|result| result.file.is_file());
1532 let more_available = ranked.more_available || semantic_more_available;
1533 let snippets_incomplete =
1534 enrich_snippets_from_source_with_context(&mut ranked.results, &external_root, Some(ctx));
1535 let display_root = absolute_display_root(&external_root);
1536 let mut text = format_semantic_text_with_display_root(
1537 &ranked.results,
1538 &display_root,
1539 more_available,
1540 snippets_incomplete,
1541 Some(ctx),
1542 );
1543 if semantic_status != "ready" {
1544 let disclosure = if semantic_status == "building" {
1545 BORROWED_SEMANTIC_LOADING_WITH_LEXICAL_RESULTS
1546 } else {
1547 "Semantic search is not available for the external root; lexical engine results follow."
1548 };
1549 text = format!("{disclosure}\n\n{text}");
1550 }
1551 if let Some(line) = ranked.confidence_line {
1552 text.push_str("\n\n");
1553 text.push_str(line);
1554 }
1555 let mut extras = external_response_extras(&external_root, &borrow_metadata)
1556 .as_object()
1557 .cloned()
1558 .unwrap_or_default();
1559 crate::list_surfaces::search::attach_projected_search_envelope(
1560 &mut extras,
1561 &ranked.results_list_envelope,
1562 );
1563 extras.insert("structuredContent".to_string(), ranked.structured_content);
1564 extras.insert(
1565 "lexical_only_fallback".to_string(),
1566 serde_json::json!(semantic_status != "ready"),
1567 );
1568 extras.insert(
1569 "semantic_unavailable".to_string(),
1570 serde_json::json!(semantic_status != "ready"),
1571 );
1572 extras.insert(
1573 "lexical_engine_capped".to_string(),
1574 serde_json::json!(ranked.engine_capped),
1575 );
1576
1577 search_response(
1578 req,
1579 SearchResponseParts {
1580 query: ¶ms.query,
1581 interpreted_as: if semantic_status == "ready" {
1582 interpreted_as_label(mode)
1583 } else {
1584 "lexical"
1585 },
1586 query_kind: query_kind_label(shape.kind),
1587 semantic_status,
1588 status: if semantic_status == "building" {
1589 "building"
1590 } else {
1591 "ready"
1592 },
1593 complete: semantic_status == "ready" && !borrow_metadata.stale_cli_snapshot(),
1594 text,
1595 results: ranked.results.iter().map(result_to_json).collect(),
1596 more_available,
1597 engine_capped: ranked.engine_capped,
1598 fully_degraded: false,
1599 warnings,
1600 extras,
1601 },
1602 )
1603}
1604
1605fn semantic_fingerprint_matches_session(ctx: &AppContext, index: &SemanticIndex) -> bool {
1606 let config = ctx.config().semantic.clone();
1607 let expected = SemanticIndexFingerprint::for_config_dimension(&config, index.dimension());
1608 index
1609 .fingerprint()
1610 .map(|fingerprint| fingerprint.as_string() == expected.as_string())
1611 .unwrap_or(false)
1612}
1613
1614fn stale_cli_snapshot_partial_response(
1615 mut response: Response,
1616 stale_cli_snapshot: bool,
1617) -> Response {
1618 if stale_cli_snapshot {
1619 response.data["complete"] = serde_json::Value::Bool(false);
1620 }
1621 response
1622}
1623
1624fn external_response_extras(
1625 external_root: &Path,
1626 borrow_metadata: &ExternalBorrowMetadata,
1627) -> serde_json::Value {
1628 let mut extras = serde_json::json!({
1629 "external_root": external_root.display().to_string(),
1630 "borrowed": true,
1631 "drift_count": borrow_metadata.drift_count,
1632 "ignore_rules_differ": borrow_metadata.ignore_rules_differ,
1633 SUPPRESS_STATUS_BAR_FIELD: true,
1634 });
1635 if let (Some(reason), Some(object)) = (borrow_metadata.degraded_reason, extras.as_object_mut())
1636 {
1637 object.insert(
1638 "borrowed_index_degraded_reason".to_string(),
1639 serde_json::json!(reason),
1640 );
1641 }
1642 extras
1643}
1644
1645fn borrowed_drift_log_message(index_kind: &str, root: &Path, drift_count: usize) -> String {
1646 format!(
1647 "borrowed {index_kind} index for {} has {drift_count} drifted file(s); serving current-disk snippets without agent-facing stale prose",
1648 root.display()
1649 )
1650}
1651
1652fn absolute_display_root(root: &Path) -> PathBuf {
1653 root.join(".aft-external-display-root-nonprefix")
1654}
1655
1656fn default_top_k() -> usize {
1657 DEFAULT_TOP_K
1658}
1659
1660fn project_relative_path<'a>(path: &'a Path, project_root: &'a Path) -> &'a Path {
1661 path.strip_prefix(project_root).unwrap_or(path)
1662}
1663
1664fn path_is_test_support_file(path: &Path, project_root: &Path) -> bool {
1665 let relative = project_relative_path(path, project_root);
1666 is_test_support_file(relative.to_string_lossy().as_ref())
1667}
1668
1669fn path_is_hidden_test_file(path: &Path, project_root: &Path) -> bool {
1674 let relative = project_relative_path(path, project_root);
1675 let rel = relative.to_string_lossy();
1676 is_test_support_file(rel.as_ref()) || is_test_file(rel.as_ref())
1677}
1678
1679fn path_allowed_by_include_tests(path: &Path, project_root: &Path, include_tests: bool) -> bool {
1680 include_tests || !path_is_hidden_test_file(path, project_root)
1681}
1682
1683fn grep_path_exclusion(include_tests: bool) -> Option<GrepPathExclusion> {
1684 (!include_tests).then_some(path_is_hidden_test_file)
1685}
1686
1687fn lexical_candidate_exactness(
1688 file: &Path,
1689 query: &str,
1690 content_tokens: &[String],
1691) -> (bool, usize, Option<usize>) {
1692 let Ok(bytes) = fs::read(file) else {
1693 return (false, 0, None);
1694 };
1695 let text = String::from_utf8_lossy(&bytes);
1696 let normalized_text = normalize_exact_phrase(&text);
1697 let normalized_phrase = normalize_exact_phrase(exact_phrase(query));
1698 let phrase_count = if normalized_phrase.is_empty() {
1699 0
1700 } else {
1701 normalized_text.matches(&normalized_phrase).count()
1702 };
1703 if phrase_count > 0 {
1704 return (true, phrase_count, Some(1));
1705 }
1706
1707 let lines = text.lines().collect::<Vec<_>>();
1708 for width in 1..=3 {
1709 if lines.len() < width {
1710 continue;
1711 }
1712 if lines.windows(width).any(|window| {
1713 query_shape::contains_all_content_tokens(&window.join("\n"), content_tokens)
1714 }) {
1715 return (true, 0, Some(width));
1716 }
1717 }
1718 (false, 0, None)
1719}
1720
1721fn exact_phrase(query: &str) -> &str {
1722 let trimmed = query.trim();
1723 if trimmed.len() < 2 {
1724 return trimmed;
1725 }
1726 let first = trimmed.as_bytes()[0];
1727 let last = trimmed.as_bytes()[trimmed.len() - 1];
1728 if matches!(first, b'\'' | b'"') && first == last {
1729 &trimmed[1..trimmed.len() - 1]
1730 } else {
1731 trimmed
1732 }
1733}
1734
1735fn normalize_exact_phrase(text: &str) -> String {
1736 text.split_whitespace()
1737 .collect::<Vec<_>>()
1738 .join(" ")
1739 .to_ascii_lowercase()
1740}
1741
1742fn choose_mode(
1743 query: &str,
1744 shape: &QueryShape,
1745 lexical_ready: bool,
1746 warnings: &mut Vec<String>,
1747) -> SearchMode {
1748 if shape.kind == QueryKind::Regex {
1749 return SearchMode::Regex;
1750 }
1751 if shape.kind != QueryKind::NaturalLanguage && extracted_tokens_all_short(query, shape) {
1752 warnings.push(
1753 "Auto mode is using literal full-file scan for all-short exact tokens because the trigram index cannot rank tokens shorter than 3 chars.".to_string(),
1754 );
1755 return SearchMode::Literal;
1756 }
1757 if lexical_ready {
1758 SearchMode::Hybrid
1759 } else {
1760 warnings
1761 .push("Lexical trigram index is unavailable; using semantic search only.".to_string());
1762 SearchMode::Semantic
1763 }
1764}
1765
1766fn handle_grep_search(
1767 req: &RawRequest,
1768 ctx: &AppContext,
1769 query: &str,
1770 offset: usize,
1771 top_k: usize,
1772 shape: &QueryShape,
1773 mode: SearchMode,
1774 semantic_status: &'static str,
1775 mut warnings: Vec<String>,
1776 project_root: &Path,
1777 include_tests: bool,
1778 page_request: paging::ValidatedPageRequest,
1779 extensions: &dyn extensions::SearchExtensions,
1780 engine_plan: &extensions::LanePlan<'_>,
1781) -> Response {
1782 let auto_regex = mode == SearchMode::Regex;
1783 let mut effective_mode = mode;
1784 let compile_literal_fallback = || -> Result<_, Response> {
1785 match pattern_compile::compile(
1786 query,
1787 CompileOpts {
1788 literal: true,
1789 ..CompileOpts::default()
1790 },
1791 ) {
1792 CompileResult::Ok(compiled) => Ok(compiled),
1793 CompileResult::InvalidPattern { message, .. } => Err(Response::error_with_data(
1794 &req.id,
1795 "invalid_pattern",
1796 message,
1797 serde_json::json!({"pattern": query}),
1798 )),
1799 CompileResult::UnsupportedSyntax { feature, .. } => Err(Response::error_with_data(
1800 &req.id,
1801 "unsupported_pattern",
1802 format!(
1803 "Pattern uses regex syntax not supported by AFT's engine: {feature}. Rewrite without {feature} or use grep for explicit regex control."
1804 ),
1805 serde_json::json!({"pattern": query, "feature": feature}),
1806 )),
1807 }
1808 };
1809
1810 let compiled = match pattern_compile::compile(
1811 query,
1812 CompileOpts {
1813 literal: mode == SearchMode::Literal,
1814 ..CompileOpts::default()
1815 },
1816 ) {
1817 CompileResult::Ok(compiled) => compiled,
1818 CompileResult::InvalidPattern { message, .. } => {
1819 if auto_regex {
1820 warnings.push(auto_regex_literal_fallback_warning(
1821 short_regex_compile_reason(&message),
1822 ));
1823 effective_mode = SearchMode::Literal;
1824 match compile_literal_fallback() {
1825 Ok(compiled) => compiled,
1826 Err(response) => return response,
1827 }
1828 } else {
1829 return Response::error_with_data(
1830 &req.id,
1831 "invalid_pattern",
1832 message,
1833 serde_json::json!({"pattern": query}),
1834 );
1835 }
1836 }
1837 CompileResult::UnsupportedSyntax { feature, .. } => {
1838 if auto_regex {
1839 warnings.push(auto_regex_literal_fallback_warning(format!(
1840 "{feature} is not supported"
1841 )));
1842 effective_mode = SearchMode::Literal;
1843 match compile_literal_fallback() {
1844 Ok(compiled) => compiled,
1845 Err(response) => return response,
1846 }
1847 } else {
1848 return Response::error_with_data(
1849 &req.id,
1850 "unsupported_pattern",
1851 format!(
1852 "Pattern uses regex syntax not supported by AFT's engine: {feature}. Rewrite without {feature} or use grep for explicit regex control."
1853 ),
1854 serde_json::json!({"pattern": query, "feature": feature}),
1855 );
1856 }
1857 }
1858 };
1859
1860 let literal = effective_mode == SearchMode::Literal;
1861 let fetch_limit = offset.saturating_add(top_k);
1862 let scope = match grep_executor::resolve_grep_scope(ctx, None, fetch_limit, &req.id) {
1863 Ok(scope) => scope,
1864 Err(response) => return response,
1865 };
1866 let params = GrepParams {
1867 include: Vec::new(),
1868 exclude: Vec::new(),
1869 max_results: fetch_limit,
1870 path_exclusion: grep_path_exclusion(include_tests),
1871 };
1872 let mut result = grep_executor::execute(ctx, &compiled, &scope, ¶ms);
1873 if result.fully_degraded {
1874 warnings.push(degraded_warning(ctx));
1875 }
1876
1877 let result_source = if literal { "literal" } else { "regex" };
1878 if result.matches.is_empty() && search_index_ready(ctx) {
1879 return zero_result_escalation_response(
1880 req,
1881 ctx,
1882 query,
1883 shape,
1884 effective_mode,
1885 semantic_status,
1886 warnings,
1887 include_tests,
1888 project_root,
1889 project_root,
1890 serde_json::Map::new(),
1891 page_request,
1892 extensions,
1893 engine_plan,
1894 None,
1895 );
1896 }
1897
1898 let interval_end = offset.saturating_add(top_k);
1899 let interval_has_more = result.total_matches > interval_end || result.truncated;
1900 result.matches = result
1901 .matches
1902 .into_iter()
1903 .skip(offset)
1904 .take(top_k)
1905 .collect();
1906 let result_values = result
1907 .matches
1908 .iter()
1909 .map(|grep_match| grep_match_to_json(grep_match, result_source))
1910 .collect::<Vec<_>>();
1911 let interpreted_as = interpreted_as_label(effective_mode);
1912 let trigram_index_building = semantic_status == "building"
1913 && matches!(
1914 result.index_status,
1915 IndexStatus::Building | IndexStatus::Fallback
1916 );
1917 let mut text = format_grep_search_text(&result, project_root, interpreted_as);
1918 let mut extras = serde_json::Map::new();
1919 if trigram_index_building {
1920 let envelope = bounded_walk_search_envelope(
1921 result_values.len(),
1922 interval_has_more,
1923 result.engine_capped,
1924 );
1925 text = format!("{TRIGRAM_BUILDING_BOUNDED_WALK_DISCLOSURE}\n\n{text}");
1929 extras.insert(
1930 crate::list_surfaces::search::SEARCH_WIRE_KEY.to_string(),
1931 serde_json::json!(envelope),
1932 );
1933 extras.insert("lexical_only_fallback".to_string(), serde_json::json!(true));
1934 extras.insert("semantic_unavailable".to_string(), serde_json::json!(true));
1935 } else if let Some(envelope) =
1936 search_cut_envelope(result_values.len(), interval_has_more, result.engine_capped)
1937 {
1938 crate::list_surfaces::search::attach_projected_search_envelope(&mut extras, &envelope);
1939 }
1940 search_response(
1941 req,
1942 SearchResponseParts {
1943 query,
1944 interpreted_as,
1945 query_kind: query_kind_label(shape.kind),
1946 semantic_status,
1947 status: if trigram_index_building {
1948 "partial"
1949 } else {
1950 "ready"
1951 },
1952 complete: !trigram_index_building,
1953 text,
1954 results: result_values,
1955 more_available: interval_has_more,
1956 engine_capped: result.engine_capped,
1957 fully_degraded: result.fully_degraded,
1958 warnings,
1959 extras,
1960 },
1961 )
1962}
1963
1964fn short_regex_compile_reason(message: &str) -> Cow<'_, str> {
1965 let trimmed = message.trim();
1966 let reason = trimmed
1967 .lines()
1968 .rev()
1969 .map(str::trim)
1970 .find(|line| !line.is_empty() && !line.chars().all(|ch| ch == '^'))
1971 .unwrap_or(trimmed);
1972 Cow::Borrowed(
1973 reason
1974 .strip_prefix("error: ")
1975 .or_else(|| reason.strip_prefix("invalid regex: "))
1976 .unwrap_or(reason),
1977 )
1978}
1979
1980fn auto_regex_literal_fallback_warning(reason: impl AsRef<str>) -> String {
1981 format!(
1982 "Query looked like a regex but failed to compile ({}); searched literally instead. Use grep when explicit regex lane control is required.",
1983 reason.as_ref()
1984 )
1985}
1986
1987fn view_semantic_search(
1988 view: &crate::context::ViewRuntimeSnapshot,
1989 project_root: &Path,
1990 query_vector: &[f32],
1991 limit: usize,
1992 include_tests: bool,
1993) -> Result<Vec<SemanticResult>, String> {
1994 let Some(manifest) = view.manifest.as_ref() else {
1995 return Ok(Vec::new());
1996 };
1997 let database = view
1998 .storage
1999 .join("blobs")
2000 .join(&view.family)
2001 .join("semantic.sqlite");
2002 let connection = Connection::open_with_flags(
2003 database,
2004 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
2005 )
2006 .map_err(|error| error.to_string())?;
2007 let mut results = Vec::new();
2008 for (rel_path, entry) in manifest.entries() {
2009 let crate::views::ManifestEntry::Regular { planes, .. } = entry else {
2010 continue;
2011 };
2012 let Some(key) = planes.semantic.as_deref().and_then(decode_view_key) else {
2013 continue;
2014 };
2015 let payload = connection
2016 .query_row(
2017 "SELECT payload FROM blob_payloads WHERE full_key = ?1",
2018 [key],
2019 |row| row.get::<_, Vec<u8>>(0),
2020 )
2021 .optional()
2022 .map_err(|error| error.to_string())?;
2023 let Some(payload) = payload else {
2024 continue;
2025 };
2026 let file = project_root.join(String::from_utf8_lossy(rel_path.as_bytes()).as_ref());
2027 if !path_allowed_by_include_tests(&file, project_root, include_tests) {
2028 continue;
2029 }
2030 decode_view_semantic_payload(&payload, &file, query_vector, &mut results)?;
2031 }
2032 results.sort_by(|left, right| {
2033 right
2034 .score
2035 .total_cmp(&left.score)
2036 .then_with(|| left.file.cmp(&right.file))
2037 .then_with(|| left.start_line.cmp(&right.start_line))
2038 });
2039 results.truncate(limit);
2040 Ok(results)
2041}
2042
2043fn decode_view_key(value: &str) -> Option<Vec<u8>> {
2044 if value.len() != 64 {
2045 return None;
2046 }
2047 (0..value.len())
2048 .step_by(2)
2049 .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
2050 .collect()
2051}
2052
2053fn decode_view_semantic_payload(
2054 payload: &[u8],
2055 file: &Path,
2056 query_vector: &[f32],
2057 results: &mut Vec<SemanticResult>,
2058) -> Result<(), String> {
2059 let mut cursor = 0usize;
2060 let version = take_view_bytes(payload, &mut cursor, 1)?[0];
2061 if version != 1 {
2062 return Err(format!(
2063 "unsupported semantic view payload version {version}"
2064 ));
2065 }
2066 for _ in 0..3 {
2067 let _ = take_view_field(payload, &mut cursor)?;
2068 }
2069 let count = u32::from_le_bytes(
2070 take_view_bytes(payload, &mut cursor, 4)?
2071 .try_into()
2072 .map_err(|_| "invalid semantic entry count".to_string())?,
2073 );
2074 for _ in 0..count {
2075 let name = String::from_utf8(take_view_field(payload, &mut cursor)?.to_vec())
2076 .map_err(|error| error.to_string())?;
2077 let qualified = String::from_utf8(take_view_field(payload, &mut cursor)?.to_vec())
2078 .map_err(|error| error.to_string())?;
2079 let kind = view_symbol_kind(take_view_bytes(payload, &mut cursor, 1)?[0]);
2080 let start_line = u32::from_le_bytes(
2081 take_view_bytes(payload, &mut cursor, 4)?
2082 .try_into()
2083 .unwrap(),
2084 );
2085 let end_line = u32::from_le_bytes(
2086 take_view_bytes(payload, &mut cursor, 4)?
2087 .try_into()
2088 .unwrap(),
2089 );
2090 let exported = take_view_bytes(payload, &mut cursor, 1)?[0] != 0;
2091 let snippet = String::from_utf8(take_view_field(payload, &mut cursor)?.to_vec())
2092 .map_err(|error| error.to_string())?;
2093 let _embed_text = take_view_field(payload, &mut cursor)?;
2094 let vector_bytes = take_view_field(payload, &mut cursor)?;
2095 if vector_bytes.len() % 4 != 0 {
2096 return Err("semantic view vector has invalid byte length".to_string());
2097 }
2098 let vector = vector_bytes
2099 .chunks_exact(4)
2100 .map(|bytes| f32::from_le_bytes(bytes.try_into().unwrap()))
2101 .collect::<Vec<_>>();
2102 if vector.len() != query_vector.len() {
2103 continue;
2104 }
2105 let dot = vector
2106 .iter()
2107 .zip(query_vector)
2108 .map(|(left, right)| left * right)
2109 .sum::<f32>();
2110 let left_norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
2111 let right_norm = query_vector
2112 .iter()
2113 .map(|value| value * value)
2114 .sum::<f32>()
2115 .sqrt();
2116 let score = if left_norm == 0.0 || right_norm == 0.0 {
2117 0.0
2118 } else {
2119 dot / (left_norm * right_norm)
2120 };
2121 results.push(SemanticResult {
2122 file: file.to_path_buf(),
2123 name,
2124 qualified_name: (!qualified.is_empty()).then_some(qualified),
2125 kind,
2126 start_line,
2127 end_line,
2128 exported,
2129 snippet,
2130 score,
2131 rank_score: score,
2132 cap_protected: false,
2133 source: "semantic",
2134 });
2135 }
2136 Ok(())
2137}
2138
2139fn take_view_field<'a>(payload: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], String> {
2140 let length = u32::from_le_bytes(
2141 take_view_bytes(payload, cursor, 4)?
2142 .try_into()
2143 .map_err(|_| "invalid semantic field length".to_string())?,
2144 ) as usize;
2145 take_view_bytes(payload, cursor, length)
2146}
2147
2148fn take_view_bytes<'a>(
2149 payload: &'a [u8],
2150 cursor: &mut usize,
2151 length: usize,
2152) -> Result<&'a [u8], String> {
2153 let end = cursor
2154 .checked_add(length)
2155 .ok_or_else(|| "semantic view payload length overflow".to_string())?;
2156 let bytes = payload
2157 .get(*cursor..end)
2158 .ok_or_else(|| "truncated semantic view payload".to_string())?;
2159 *cursor = end;
2160 Ok(bytes)
2161}
2162
2163fn view_symbol_kind(value: u8) -> SymbolKind {
2164 match value {
2165 0 => SymbolKind::Function,
2166 1 => SymbolKind::Class,
2167 2 => SymbolKind::Method,
2168 3 => SymbolKind::Struct,
2169 4 => SymbolKind::Interface,
2170 5 => SymbolKind::Enum,
2171 6 => SymbolKind::TypeAlias,
2172 7 => SymbolKind::Variable,
2173 9 => SymbolKind::FileSummary,
2174 _ => SymbolKind::Heading,
2175 }
2176}
2177
2178#[derive(Clone)]
2179struct PreparedEngineLane {
2180 kind: SearchLaneKind,
2181 candidates: Vec<CandidateResult>,
2182}
2183
2184impl SearchLane for PreparedEngineLane {
2185 fn kind(&self) -> SearchLaneKind {
2186 self.kind
2187 }
2188
2189 fn execute(&self, _input: &LaneInput<'_>) -> LaneExecution {
2190 LaneExecution {
2191 kind: self.kind,
2192 candidates: self.candidates.clone(),
2193 }
2194 }
2195}
2196
2197struct EngineRanking {
2198 results: Vec<HybridResult>,
2199 more_available: bool,
2200 engine_capped: bool,
2201 results_list_envelope: ListEnvelope,
2202 confidence_line: Option<&'static str>,
2203 structured_content: serde_json::Value,
2204}
2205
2206fn matching_line_from_source(
2207 file: &Path,
2208 query: &str,
2209 symbol_range: Option<SymbolOffsetRange>,
2210) -> Option<(u32, String)> {
2211 let source = std::fs::read_to_string(file).ok()?;
2212 let normalized_phrase = exact_lane::normalize_exact_phrase(exact_lane::exact_phrase(query));
2213 if !normalized_phrase.is_empty() {
2214 if let Some((line_index, line)) = source
2215 .lines()
2216 .enumerate()
2217 .find(|(_, line)| exact_lane::normalize_exact_phrase(line).contains(&normalized_phrase))
2218 {
2219 return Some((u32::try_from(line_index).ok()?, line.to_string()));
2220 }
2221 }
2222
2223 let content_tokens = query_shape::extract_content_tokens(query);
2224 let compact_tokens = content_tokens
2225 .iter()
2226 .map(|token| {
2227 token
2228 .chars()
2229 .filter(|character| character.is_alphanumeric())
2230 .flat_map(char::to_lowercase)
2231 .collect::<String>()
2232 })
2233 .filter(|token| token.len() >= 3)
2234 .collect::<Vec<_>>();
2235 let best = source
2236 .lines()
2237 .enumerate()
2238 .filter_map(|(line_index, line)| {
2239 let compact_line = line
2240 .chars()
2241 .filter(|character| character.is_alphanumeric())
2242 .flat_map(char::to_lowercase)
2243 .collect::<String>();
2244 let score = compact_tokens
2245 .iter()
2246 .map(|token| {
2247 if compact_line.contains(token) {
2248 token.len().saturating_mul(4)
2249 } else {
2250 token
2251 .as_bytes()
2252 .windows(3)
2253 .filter(|trigram| {
2254 compact_line
2255 .as_bytes()
2256 .windows(3)
2257 .any(|candidate| candidate == *trigram)
2258 })
2259 .count()
2260 }
2261 })
2262 .sum::<usize>();
2263 (score > 0).then_some((score, line_index, line))
2264 })
2265 .max_by(|left, right| left.0.cmp(&right.0).then_with(|| right.1.cmp(&left.1)));
2266 if let Some((_, line_index, line)) = best {
2267 return Some((u32::try_from(line_index).ok()?, line.to_string()));
2268 }
2269
2270 let offset = symbol_range?.start.min(source.len());
2271 let line_index = source.as_bytes()[..offset]
2272 .iter()
2273 .filter(|byte| **byte == b'\n')
2274 .count();
2275 Some((
2276 u32::try_from(line_index).ok()?,
2277 source.lines().nth(line_index)?.to_string(),
2278 ))
2279}
2280
2281fn definition_matches_identifier_token(candidate: &CandidateResult, query: &str) -> bool {
2282 let Some(range) = candidate.symbol_range else {
2283 return false;
2284 };
2285 let Ok(source) = std::fs::read(&candidate.path) else {
2286 return false;
2287 };
2288 let Some(symbol) = source.get(range.start..range.end) else {
2289 return false;
2290 };
2291 query
2292 .split_whitespace()
2293 .map(|token| {
2294 token.trim_matches(|character: char| {
2295 !character.is_alphanumeric()
2296 && character != '_'
2297 && character != ':'
2298 && character != '.'
2299 })
2300 })
2301 .filter(|token| crate::search_b2::router::is_identifier_shaped_token(token))
2302 .any(|token| {
2303 symbol
2304 .windows(token.len())
2305 .any(|window| window == token.as_bytes())
2306 })
2307}
2308
2309fn path_scope_contains(path_scope: &HashSet<PathBuf>, path: &Path) -> bool {
2310 let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
2311 path_scope.contains(&path)
2312}
2313
2314fn run_engine_ranking(
2315 request_id: &str,
2316 ctx: &AppContext,
2317 project_root: &Path,
2318 query: &str,
2319 include_tests: bool,
2320 semantic_results: Vec<SemanticResult>,
2321 page_request: paging::ValidatedPageRequest,
2322 extensions: &dyn extensions::SearchExtensions,
2323 plan: &extensions::LanePlan<'_>,
2324 borrowed_index: Option<(&SearchIndex, &GenerationToken)>,
2325) -> Result<EngineRanking, String> {
2326 use blocks::{BlockBuilder, CanonicalLane, CanonicalListKey, LaneCandidate, BLOCK_DEPTHS};
2327 use confidence::{Confidence, ConfidenceEngine};
2328 use lexical_lane::CanonicalLexicalLane;
2329 use provenance::ObservedProvenance;
2330 use scoring::ScoringPolicy;
2331 use telemetry::{ConfidenceTelemetry, TelemetryAssembler, TelemetryRun};
2332 use trailer::{ExactPassState, SearchTrailer};
2333
2334 let context_index = borrowed_index
2335 .is_none()
2336 .then(|| try_read_with_budget(ctx.search_index(), INTERACTIVE_ARTIFACT_READ_BUDGET));
2337 let empty_index = SearchIndex::new();
2338 let index = borrowed_index
2339 .map(|(index, _)| index)
2340 .or_else(|| {
2341 context_index
2342 .as_ref()
2343 .and_then(|guard| guard.as_ref())
2344 .and_then(|guard| guard.as_ref())
2345 })
2346 .unwrap_or(&empty_index);
2347 let generation = borrowed_index
2348 .map(|(_, generation)| generation.clone())
2349 .unwrap_or_else(|| {
2350 GenerationToken::new_with_str(&format!(
2351 "{}:{}",
2352 ctx.search_index_rx_generation(),
2353 ctx.semantic_index_rx_generation()
2354 ))
2355 });
2356 let snapshot = index.snapshot();
2357 let content_tokens = query_shape::extract_content_tokens(query);
2358 let token_refs = content_tokens
2359 .iter()
2360 .map(String::as_str)
2361 .collect::<Vec<_>>();
2362 let query_trigrams = SearchIndex::query_trigrams_from_tokens(&token_refs);
2363 let candidate_filter =
2364 |path: &Path| path_allowed_by_include_tests(path, project_root, include_tests);
2365 let lexical = CanonicalLexicalLane::from_snapshot(
2366 &snapshot,
2367 &query_trigrams,
2368 Some(&candidate_filter),
2369 lexical_lane::LEXICAL_ENUMERATION_LIMIT,
2370 )
2371 .map_err(|error| error.to_string())?;
2372 let lexical_scores = lexical
2373 .canonical_order()
2374 .iter()
2375 .map(|candidate| (candidate.result.path.clone(), candidate.raw_score))
2376 .collect::<HashMap<_, _>>();
2377 let lexical_candidates = lexical
2378 .canonical_order()
2379 .iter()
2380 .map(|candidate| candidate.result.clone())
2381 .collect::<Vec<_>>();
2382 let lexical_verifications = lexical
2383 .canonical_order()
2384 .iter()
2385 .take(lexical_lane::LEXICAL_ENUMERATION_LIMIT)
2386 .filter_map(|candidate| {
2387 let (exact, occurrences, window_lines) =
2388 lexical_candidate_exactness(&candidate.result.path, query, &content_tokens);
2389 if !exact {
2390 return None;
2391 }
2392 let evidence = if occurrences > 0 {
2393 EvidenceDescriptor::for_e1(occurrences, true, false)
2394 } else {
2395 EvidenceDescriptor::for_e2(window_lines?, true, false)
2396 };
2397 Some(CandidateResult::new_exact(
2398 candidate.result.path.clone(),
2399 None,
2400 evidence,
2401 ))
2402 })
2403 .collect::<Vec<_>>();
2404 let exact_input = plan.exact_input.as_deref().unwrap_or(query);
2405 let mut exact_candidates =
2406 if plan.contains(SearchLaneKind::Exact) || plan.shape == SearchShape::Identifier {
2407 exact_lane::ExactLane::with_memo(ctx.search_exact_memo())
2408 .search(
2409 Some(&index),
2410 project_root,
2411 generation.clone(),
2412 exact_input,
2413 include_tests,
2414 0,
2415 usize::MAX,
2416 None,
2417 )
2418 .map_err(|error| error.to_string())?
2419 .results
2420 } else {
2421 Vec::new()
2422 };
2423 if plan.shape == SearchShape::NaturalLanguage && plan.query_facts.has_identifier_token {
2424 let lane = exact_lane::ExactLane::with_memo(ctx.search_exact_memo());
2425 for fact in crate::search_b2::router::identifier_shaped_tokens(query) {
2426 let mut fact_inputs = vec![(fact.clone(), true)];
2427 fact_inputs.extend(
2428 extensions
2429 .variants(extensions::Token {
2430 index: 0,
2431 text: &fact,
2432 })
2433 .into_iter()
2434 .map(|variant| (variant.text, false)),
2435 );
2436 for (fact_input, exact_form) in fact_inputs {
2437 let mut fact_candidates = lane
2438 .search(
2439 Some(&index),
2440 project_root,
2441 generation.clone(),
2442 &fact_input,
2443 include_tests,
2444 0,
2445 usize::MAX,
2446 None,
2447 )
2448 .map_err(|error| error.to_string())?
2449 .results;
2450 fact_candidates.retain(|candidate| candidate.evidence.kind == EvidenceKind::E1);
2451 for candidate in &mut fact_candidates {
2452 candidate.evidence.exact_form = exact_form;
2453 }
2454 exact_candidates.extend(fact_candidates);
2455 }
2456 }
2457 }
2458 let retain_definition_evidence = plan.shape == SearchShape::Identifier
2459 || (plan.shape == SearchShape::NaturalLanguage && plan.query_facts.has_identifier_token);
2460 if !retain_definition_evidence {
2461 exact_candidates.retain(|candidate| candidate.evidence.kind != EvidenceKind::Definition);
2462 } else if plan.shape == SearchShape::NaturalLanguage {
2463 exact_candidates.retain(|candidate| {
2464 candidate.evidence.kind != EvidenceKind::Definition
2465 || definition_matches_identifier_token(candidate, query)
2466 });
2467 }
2468 exact_candidates.extend(lexical_verifications);
2469 exact_candidates.sort_by(score_free_r3_cmp);
2470 let mut seen_exact = HashSet::new();
2477 exact_candidates.retain(|candidate| seen_exact.insert(candidate.path.clone()));
2478
2479 let path_lookup_candidates = if plan.contains(SearchLaneKind::PathLookup) {
2480 let query_path_tokens = query
2481 .split_whitespace()
2482 .map(|token| {
2483 token.trim_matches(|ch: char| {
2484 !ch.is_alphanumeric()
2485 && ch != '.'
2486 && ch != '_'
2487 && ch != '-'
2488 && ch != '/'
2489 && ch != '\\'
2490 })
2491 })
2492 .filter(|token| token.contains('.'))
2493 .collect::<Vec<_>>();
2494 walk_project_files_from(project_root, project_root, &PathFilters::default())
2495 .into_iter()
2496 .filter(|path| candidate_filter(path))
2497 .filter(|path| {
2498 let relative = path
2499 .strip_prefix(project_root)
2500 .unwrap_or(path)
2501 .to_string_lossy()
2502 .replace('\\', "/");
2503 let file_name = path.file_name().and_then(|name| name.to_str());
2504 query_path_tokens.iter().any(|token| {
2505 let normalized = token.replace('\\', "/");
2506 file_name == Some(normalized.as_str()) || relative.ends_with(&normalized)
2507 })
2508 })
2509 .map(|path| {
2510 CandidateResult::new_exact(path, None, EvidenceDescriptor::for_e1(1, true, false))
2511 })
2512 .collect()
2513 } else {
2514 Vec::new()
2515 };
2516 let path_scope =
2517 (!path_lookup_candidates.is_empty() && plan.query_facts.has_path_token).then(|| {
2518 path_lookup_candidates
2519 .iter()
2520 .map(|candidate| {
2521 std::fs::canonicalize(&candidate.path)
2522 .unwrap_or_else(|_| candidate.path.clone())
2523 })
2524 .collect::<HashSet<_>>()
2525 });
2526 if let Some(path_scope) = &path_scope {
2527 let (mut in_scope, out_of_scope): (Vec<_>, Vec<_>) =
2528 exact_candidates.into_iter().partition(|candidate| {
2529 let path = std::fs::canonicalize(&candidate.path)
2530 .unwrap_or_else(|_| candidate.path.clone());
2531 path_scope.contains(&path)
2532 });
2533 in_scope.extend(out_of_scope);
2534 exact_candidates = in_scope;
2535 }
2536
2537 let mut semantic_metadata = HashMap::new();
2538 let mut seen_semantic_paths = HashSet::new();
2539 let mut prepared_semantic = Vec::new();
2540 for result in semantic_results {
2541 let path = result.file.clone();
2542 semantic_metadata
2543 .entry(path.clone())
2544 .or_insert_with(|| HybridResult {
2545 file: result.file.clone(),
2546 name: result.name.clone(),
2547 kind: result.kind,
2548 start_line: result.start_line,
2549 end_line: result.end_line,
2550 exported: result.exported,
2551 score: result.score,
2552 source: "semantic",
2553 semantic_score: Some(result.score),
2554 lexical_score: None,
2555 hybrid_boosted: false,
2556 exact: false,
2557 exact_phrase_count: 0,
2558 exact_window_lines: None,
2559 fusion_score: 0.0,
2560 snippet: result.snippet.clone(),
2561 });
2562 if seen_semantic_paths.insert(path.clone()) {
2563 prepared_semantic.push(CandidateResult {
2564 path,
2565 symbol_range: None,
2566 evidence: EvidenceDescriptor::for_non_exact(false, false),
2567 fusion_score: None,
2568 lane_score: Some(result.score),
2569 best_lane: Some(SearchLaneKind::Semantic),
2570 });
2571 }
2572 }
2573
2574 let mut identifier_exact_capped = false;
2575 let lexical_execution_candidates =
2576 if plan.shape == SearchShape::Identifier && !plan.contains(SearchLaneKind::Symbol) {
2577 let fallback_depth = BLOCK_DEPTHS
2578 .iter()
2579 .copied()
2580 .find(|depth| (*depth as u64) >= page_request.interval_end())
2581 .unwrap_or_else(|| *BLOCK_DEPTHS.last().expect("block depths are non-empty"));
2582 identifier_exact_capped = exact_candidates.len() > fallback_depth;
2583 let bounded_exact = exact_candidates
2584 .iter()
2585 .take(fallback_depth)
2586 .cloned()
2587 .collect::<Vec<_>>();
2588 let exact_identities = bounded_exact
2589 .iter()
2590 .map(|candidate| (candidate.path.clone(), candidate.symbol_range))
2591 .collect::<HashSet<_>>();
2592 bounded_exact
2593 .into_iter()
2594 .chain(
2595 lexical_candidates
2596 .iter()
2597 .filter(|candidate| {
2598 !exact_identities
2599 .contains(&(candidate.path.clone(), candidate.symbol_range))
2600 })
2601 .cloned(),
2602 )
2603 .collect()
2604 } else {
2605 lexical_candidates.clone()
2606 };
2607
2608 let mut registry = LaneRegistry::new();
2609 for kind in &plan.executed_callbacks {
2610 let lane: Arc<dyn SearchLane> = match kind {
2611 SearchLaneKind::Symbol => Arc::new(PreparedEngineLane {
2612 kind: *kind,
2613 candidates: exact_candidates.clone(),
2614 }),
2615 SearchLaneKind::Exact => Arc::new(PreparedEngineLane {
2616 kind: *kind,
2617 candidates: exact_candidates.clone(),
2618 }),
2619 SearchLaneKind::Anchored => Arc::new(anchored_lane::AnchoredLane::new()),
2620 SearchLaneKind::Lexical => Arc::new(PreparedEngineLane {
2621 kind: *kind,
2622 candidates: lexical_execution_candidates.clone(),
2623 }),
2624 SearchLaneKind::Semantic => Arc::new(PreparedEngineLane {
2625 kind: *kind,
2626 candidates: prepared_semantic.clone(),
2627 }),
2628 SearchLaneKind::PathLookup => Arc::new(PreparedEngineLane {
2629 kind: *kind,
2630 candidates: path_lookup_candidates.clone(),
2631 }),
2632 _ => Arc::new(PreparedEngineLane {
2633 kind: *kind,
2634 candidates: Vec::new(),
2635 }),
2636 };
2637 register_lane(&mut registry, lane);
2638 }
2639
2640 let input = LaneInput {
2641 query,
2642 shape: plan.shape,
2643 root: project_root,
2644 include_tests,
2645 index: &index,
2646 };
2647 let mut executions = Vec::new();
2648 let mut callback_counts = HashMap::new();
2649 for kind in &plan.executed_callbacks {
2650 let lane = registry
2651 .get(*kind)
2652 .ok_or_else(|| format!("selected callback {kind} was not registered"))?;
2653 let execution = extensions.execute_lane(lane.as_ref(), &input);
2654 *callback_counts.entry(*kind).or_insert(0usize) += 1;
2655 if execution.kind != *kind {
2656 return Err(format!(
2657 "selected callback {kind} returned execution for {}",
2658 execution.kind
2659 ));
2660 }
2661 if plan.selected_lanes.contains(kind) {
2662 executions.push(execution);
2663 }
2664 }
2665
2666 let mut canonical_descriptors = HashMap::new();
2667 for candidate in executions
2668 .iter()
2669 .flat_map(|execution| execution.candidates.iter())
2670 .filter(|candidate| candidate.evidence.tier == EvidenceTier::NonExact)
2671 {
2672 canonical_descriptors
2673 .entry((candidate.path.clone(), candidate.symbol_range))
2674 .and_modify(|(exact_form, generated): &mut (bool, bool)| {
2675 *exact_form |= candidate.evidence.exact_form;
2676 *generated &= candidate.evidence.generated;
2677 })
2678 .or_insert((candidate.evidence.exact_form, candidate.evidence.generated));
2679 }
2680
2681 let mut lanes = Vec::new();
2682 for execution in executions {
2683 let candidates = execution
2684 .candidates
2685 .into_iter()
2686 .map(|candidate| {
2687 let is_test = path_is_hidden_test_file(&candidate.path, project_root);
2688 match candidate.evidence.tier {
2689 EvidenceTier::Exact => LaneCandidate::exact(
2690 candidate.path,
2691 candidate.symbol_range,
2692 candidate.evidence,
2693 is_test,
2694 ),
2695 EvidenceTier::NonExact => {
2696 let (exact_form, generated) = canonical_descriptors
2697 .get(&(candidate.path.clone(), candidate.symbol_range))
2698 .copied()
2699 .expect("every non-exact candidate has a canonical descriptor");
2700 LaneCandidate::non_exact(
2701 candidate.path,
2702 candidate.symbol_range,
2703 EvidenceDescriptor::for_non_exact(exact_form, generated),
2704 candidate
2705 .lane_score
2706 .expect("prepared non-exact candidates carry a raw lane score"),
2707 is_test,
2708 )
2709 }
2710 }
2711 })
2712 .collect();
2713 lanes.push(
2714 CanonicalLane::new(execution.kind, candidates).map_err(|error| error.to_string())?,
2715 );
2716 }
2717
2718 let key = CanonicalListKey {
2719 project_root: project_root.to_path_buf(),
2720 snapshot_generation: generation.as_str().to_string(),
2721 normalized_query: exact_lane::normalize_exact_phrase(query),
2722 include_tests,
2723 };
2724 let policy = ScoringPolicy::from_plan_table(&PlanTable::running_table(), plan.shape)
2725 .map_err(|error| error.to_string())?;
2726 let builder = BlockBuilder::new(key, policy, lanes).map_err(|error| error.to_string())?;
2727 let mut page =
2728 paging::serve_public_page(&builder, page_request).map_err(|error| error.to_string())?;
2729 if let Some(path_scope) = &path_scope {
2730 let mut order_index = 0;
2731 for block in &mut page.reply.canonical_list.blocks {
2732 let entries = std::mem::take(&mut block.entries);
2733 let (exact, non_exact): (Vec<_>, Vec<_>) = entries
2734 .into_iter()
2735 .partition(|entry| entry.result.evidence.tier == EvidenceTier::Exact);
2736 let (mut exact_in_scope, exact_outside): (Vec<_>, Vec<_>) = exact
2737 .into_iter()
2738 .partition(|entry| path_scope_contains(path_scope, &entry.result.path));
2739 let (mut non_exact_in_scope, non_exact_outside): (Vec<_>, Vec<_>) = non_exact
2740 .into_iter()
2741 .partition(|entry| path_scope_contains(path_scope, &entry.result.path));
2742 exact_in_scope.extend(exact_outside);
2743 exact_in_scope.append(&mut non_exact_in_scope);
2744 exact_in_scope.extend(non_exact_outside);
2745 for entry in &mut exact_in_scope {
2746 entry.r3_order_index = order_index;
2747 order_index += 1;
2748 }
2749 block.entries = exact_in_scope;
2750 }
2751 page.reply.page = page
2752 .reply
2753 .canonical_list
2754 .entries()
2755 .skip(page_request.offset())
2756 .take(page_request.top_k())
2757 .cloned()
2758 .collect();
2759 }
2760 let confidence = ConfidenceEngine::running()
2761 .evaluate_reply(&page.reply)
2762 .map_err(|error| error.to_string())?;
2763 let confidence_telemetry = match confidence.confidence {
2764 Some(Confidence::High) => Some(ConfidenceTelemetry::High),
2765 Some(Confidence::Low) => Some(ConfidenceTelemetry::Low),
2766 None => None,
2767 };
2768 let provenance =
2769 ObservedProvenance::from_reply(&page.reply).map_err(|error| error.to_string())?;
2770 let structured = TelemetryAssembler::new(&page, provenance)
2771 .assemble(TelemetryRun {
2772 shape: plan.shape,
2773 confidence: confidence_telemetry,
2774 variants: plan.variants.clone(),
2775 embedding_calls: crate::search_b2::embed_counter::read(request_id).requested as usize,
2776 snapshot_generation: generation,
2777 })
2778 .map_err(|error| error.to_string())?;
2779 let mut structured_content =
2780 serde_json::to_value(structured).map_err(|error| error.to_string())?;
2781 if let Some(plan_object) = structured_content
2782 .get_mut("plan")
2783 .and_then(serde_json::Value::as_object_mut)
2784 {
2785 plan_object.insert(
2786 "callback_counts".to_string(),
2787 serde_json::Value::Object(
2788 callback_counts
2789 .into_iter()
2790 .map(|(lane, count)| (lane.as_str().to_string(), serde_json::json!(count)))
2791 .collect(),
2792 ),
2793 );
2794 }
2795 let results_list_envelope = SearchTrailer::from_page(&page, ExactPassState::Complete)
2796 .map_err(|error| error.to_string())?
2797 .shared_envelope_projection();
2798
2799 let mut results = Vec::with_capacity(page.reply.page.len());
2800 for entry in &page.reply.page {
2801 let ranked = &entry.result;
2802 let semantic_backed = semantic_metadata.contains_key(&ranked.path);
2803 let mut result = semantic_metadata
2804 .remove(&ranked.path)
2805 .unwrap_or_else(|| HybridResult {
2806 file: ranked.path.clone(),
2807 name: ranked
2808 .path
2809 .file_stem()
2810 .and_then(|name| name.to_str())
2811 .unwrap_or_default()
2812 .to_string(),
2813 kind: SymbolKind::FileSummary,
2814 start_line: 0,
2815 end_line: 0,
2816 exported: false,
2817 score: 0.0,
2818 source: "lexical",
2819 semantic_score: None,
2820 lexical_score: None,
2821 hybrid_boosted: false,
2822 exact: false,
2823 exact_phrase_count: 0,
2824 exact_window_lines: None,
2825 fusion_score: 0.0,
2826 snippet: String::new(),
2827 });
2828 result.exact = ranked.evidence.tier == EvidenceTier::Exact;
2829 result.exact_phrase_count = ranked.evidence.occurrences.unwrap_or_default();
2830 result.exact_window_lines = ranked.evidence.window_lines;
2831 result.fusion_score = ranked.fusion_score.unwrap_or(1.0);
2832 result.score = ranked.lane_score.unwrap_or(result.fusion_score);
2833 result.lexical_score = lexical_scores.get(&ranked.path).copied().or_else(|| {
2834 entry
2835 .admitted_contributions
2836 .iter()
2837 .find(|contribution| contribution.lane == SearchLaneKind::Lexical)
2838 .map(|contribution| contribution.raw_score)
2839 });
2840 result.hybrid_boosted = semantic_backed && result.lexical_score.is_some();
2841 result.source = match ranked.evidence.tier {
2842 EvidenceTier::Exact if semantic_backed => "semantic",
2843 EvidenceTier::Exact => match ranked.evidence.kind {
2844 EvidenceKind::Anchored => "anchored",
2845 _ => "exact",
2846 },
2847 EvidenceTier::NonExact => match ranked.best_lane {
2848 Some(SearchLaneKind::Semantic) => "semantic",
2849 Some(SearchLaneKind::Lexical) => "lexical",
2850 _ => "hybrid",
2851 },
2852 };
2853 if matches!(result.kind, SymbolKind::FileSummary)
2854 && (result.exact || result.source == "lexical")
2855 {
2856 let match_query = if result.exact { exact_input } else { query };
2857 if let Some((line, text)) =
2858 matching_line_from_source(&ranked.path, match_query, ranked.symbol_range)
2859 {
2860 result.start_line = line;
2861 result.end_line = line;
2862 result.snippet = text;
2863 }
2864 }
2865 results.push(result);
2866 }
2867
2868 let page_end = page_request.offset().saturating_add(page_request.top_k());
2869 Ok(EngineRanking {
2870 results,
2871 more_available: page_end < page.reply.canonical_list.len()
2872 || !matches!(page.stop_state, paging::StopState::S2Exhausted),
2873 engine_capped: identifier_exact_capped
2874 || matches!(page.stop_state, paging::StopState::S3DepthCap),
2875 results_list_envelope,
2876 confidence_line: confidence.flat_head_line,
2877 structured_content,
2878 })
2879}
2880
2881fn handle_engine_only_search(
2882 req: &RawRequest,
2883 ctx: &AppContext,
2884 params: &SemanticSearchParams,
2885 shape: &QueryShape,
2886 semantic_status: &'static str,
2887 mut warnings: Vec<String>,
2888 project_root: &Path,
2889 page_request: paging::ValidatedPageRequest,
2890 extensions: &dyn extensions::SearchExtensions,
2891 plan: &extensions::LanePlan<'_>,
2892) -> Response {
2893 if semantic_status != "ready" {
2894 warnings.push("Semantic search unavailable; using lexical-only fallback.".to_string());
2895 }
2896 let mut lexical_plan = plan.clone();
2897 lexical_plan
2898 .selected_lanes
2899 .retain(|lane| *lane != SearchLaneKind::Semantic);
2900 let mut ranked = match run_engine_ranking(
2901 &req.id,
2902 ctx,
2903 project_root,
2904 ¶ms.query,
2905 params.include_tests,
2906 Vec::new(),
2907 page_request,
2908 extensions,
2909 &lexical_plan,
2910 None,
2911 ) {
2912 Ok(ranked) => ranked,
2913 Err(error) => return Response::error(&req.id, "search_engine_failed", error),
2914 };
2915 let snippets_incomplete =
2916 enrich_snippets_from_source_with_context(&mut ranked.results, project_root, Some(ctx));
2917 let mut text = format_semantic_text(
2918 &ranked.results,
2919 project_root,
2920 ranked.more_available,
2921 snippets_incomplete,
2922 Some(ctx),
2923 );
2924 if semantic_status == "building" {
2925 let disclosure = if ctx.shared_artifacts_read_only() {
2926 BORROWED_SEMANTIC_LOADING_WITH_LEXICAL_RESULTS
2927 } else {
2928 "Semantic index is rebuilding; lexical fallback results follow."
2929 };
2930 text = format!("{disclosure}\n\n{text}");
2931 }
2932 if let Some(line) = ranked.confidence_line {
2933 text.push_str("\n\n");
2934 text.push_str(line);
2935 }
2936 let mut extras = serde_json::Map::new();
2937 crate::list_surfaces::search::attach_projected_search_envelope(
2938 &mut extras,
2939 &ranked.results_list_envelope,
2940 );
2941 extras.insert("structuredContent".to_string(), ranked.structured_content);
2942 extras.insert(
2943 "lexical_only_fallback".to_string(),
2944 serde_json::json!(semantic_status != "ready"),
2945 );
2946 extras.insert(
2947 "semantic_unavailable".to_string(),
2948 serde_json::json!(semantic_status != "ready"),
2949 );
2950 extras.insert(
2951 "lexical_engine_capped".to_string(),
2952 serde_json::json!(ranked.engine_capped),
2953 );
2954 if semantic_status == "building" {
2955 extras.insert(
2956 "note".to_string(),
2957 serde_json::json!(building_lexical_note(ctx.shared_artifacts_read_only())),
2958 );
2959 }
2960 search_response(
2961 req,
2962 SearchResponseParts {
2963 query: ¶ms.query,
2964 interpreted_as: if semantic_status == "ready" {
2965 "engine"
2966 } else {
2967 "lexical"
2968 },
2969 query_kind: query_kind_label(shape.kind),
2970 semantic_status,
2971 status: if semantic_status == "building" {
2972 "building"
2973 } else {
2974 "ready"
2975 },
2976 complete: semantic_status == "ready",
2977 text,
2978 results: ranked.results.iter().map(result_to_json).collect(),
2979 more_available: ranked.more_available,
2980 engine_capped: ranked.engine_capped,
2981 fully_degraded: false,
2982 warnings,
2983 extras,
2984 },
2985 )
2986}
2987
2988fn handle_semantic_or_hybrid_search(
2989 req: &RawRequest,
2990 ctx: &AppContext,
2991 params: SemanticSearchParams,
2992 top_k: usize,
2993 shape: QueryShape,
2994 mode: SearchMode,
2995 lexical_ready: bool,
2996 status: SemanticIndexStatus,
2997 semantic_status: &'static str,
2998 mut warnings: Vec<String>,
2999 project_root: &Path,
3000 page_request: paging::ValidatedPageRequest,
3001 extensions: &dyn extensions::SearchExtensions,
3002 engine_plan: &extensions::LanePlan<'_>,
3003) -> Response {
3004 match status {
3005 SemanticIndexStatus::Disabled => {
3006 return semantic_unavailable_or_fallback_response(
3007 req,
3008 ctx,
3009 ¶ms,
3010 mode,
3011 &shape,
3012 "disabled",
3013 "disabled",
3014 "Semantic search is not enabled.".to_string(),
3015 "disabled",
3016 false,
3017 warnings,
3018 project_root,
3019 top_k,
3020 page_request,
3021 extensions,
3022 engine_plan,
3023 );
3024 }
3025 SemanticIndexStatus::Failed(error) => {
3026 let retrying_read_only_snapshot = ctx.shared_artifacts_read_only()
3027 && super::configure::trigger_semantic_index_reload_if_evicted(ctx);
3028 let (semantic_status, status, detail, footer_reason) = if retrying_read_only_snapshot {
3029 (
3030 "building",
3031 "reloading",
3032 "Semantic index is reloading from the shared read-only snapshot; retry shortly."
3033 .to_string(),
3034 "reloading",
3035 )
3036 } else {
3037 (
3038 "unavailable",
3039 "unavailable",
3040 format!("Semantic search unavailable: {error}"),
3041 "unavailable",
3042 )
3043 };
3044 return semantic_unavailable_or_fallback_response(
3045 req,
3046 ctx,
3047 ¶ms,
3048 mode,
3049 &shape,
3050 semantic_status,
3051 status,
3052 detail,
3053 footer_reason,
3054 false,
3055 warnings,
3056 project_root,
3057 top_k,
3058 page_request,
3059 extensions,
3060 engine_plan,
3061 );
3062 }
3063 SemanticIndexStatus::Building { .. } => {
3064 ctx.note_index_query(
3065 crate::logging::IndexPlane::Semantic,
3066 "semantic_search",
3067 0,
3068 "building",
3069 );
3070 if mode == SearchMode::Semantic && !lexical_ready {
3071 return handle_grep_search(
3072 req,
3073 ctx,
3074 ¶ms.query,
3075 params.offset,
3076 top_k,
3077 &shape,
3078 SearchMode::Literal,
3079 "building",
3080 warnings,
3081 project_root,
3082 params.include_tests,
3083 page_request,
3084 extensions,
3085 engine_plan,
3086 );
3087 }
3088
3089 return handle_engine_only_search(
3090 req,
3091 ctx,
3092 ¶ms,
3093 &shape,
3094 "building",
3095 warnings,
3096 project_root,
3097 page_request,
3098 extensions,
3099 engine_plan,
3100 );
3101 }
3102 SemanticIndexStatus::Ready { refreshing, .. } => {
3103 if !refreshing.is_empty() {
3104 warnings.push(format!(
3105 "{} file(s) refreshing; results for those files may be temporarily missing",
3106 refreshing.len()
3107 ));
3108 }
3109 ctx.note_index_query(
3110 crate::logging::IndexPlane::Semantic,
3111 "semantic_search",
3112 0,
3113 if refreshing.is_empty() {
3114 "ok"
3115 } else {
3116 "partial"
3117 },
3118 );
3119 }
3120 }
3121
3122 let pinned_semantic_view = ctx.pinned_view_runtime().filter(|view| {
3123 view.manifest.as_ref().is_some_and(|manifest| {
3124 manifest.entries().any(|(_, entry)| {
3125 matches!(
3126 entry,
3127 crate::views::ManifestEntry::Regular { planes, .. }
3128 if planes.semantic.is_some()
3129 )
3130 })
3131 })
3132 });
3133 let semantic_loaded = match semantic_index_loaded_with_budget(ctx) {
3134 Ok(loaded) => loaded,
3135 Err(()) => {
3136 return artifact_contention_fallback_response(
3137 req,
3138 ctx,
3139 ¶ms,
3140 &shape,
3141 project_root,
3142 top_k,
3143 "semantic index remained busy",
3144 );
3145 }
3146 };
3147 if !semantic_loaded && pinned_semantic_view.is_none() {
3148 let reloading = super::configure::trigger_semantic_index_reload_if_evicted(ctx);
3149 let detail = if reloading {
3150 "Semantic index is reloading; retry shortly."
3151 } else {
3152 "Semantic index is not ready yet."
3153 };
3154 return semantic_unavailable_or_fallback_response(
3155 req,
3156 ctx,
3157 ¶ms,
3158 mode,
3159 &shape,
3160 "unavailable",
3161 "not_ready",
3162 detail.to_string(),
3163 "not_ready",
3164 false,
3165 warnings,
3166 project_root,
3167 top_k,
3168 page_request,
3169 extensions,
3170 engine_plan,
3171 );
3172 }
3173
3174 let query_vector = match embed_query(¶ms.query, ctx) {
3175 Ok(query_vector) => query_vector,
3176 Err(error) => {
3177 if search_cancellation_requested() {
3178 return cancelled_search_response(req);
3179 }
3180 let classified = classify_embed_query_error(&error);
3181 return semantic_unavailable_or_fallback_response(
3182 req,
3183 ctx,
3184 ¶ms,
3185 mode,
3186 &shape,
3187 "unavailable",
3188 "unavailable",
3189 classified.detail,
3190 classified.footer_reason,
3191 true,
3192 warnings,
3193 project_root,
3194 top_k,
3195 page_request,
3196 extensions,
3197 engine_plan,
3198 );
3199 }
3200 };
3201 if search_cancellation_requested() {
3202 return cancelled_search_response(req);
3203 }
3204
3205 let semantic_limit = SEMANTIC_ENUMERATION_LIMIT;
3208 let semantic_fetch_limit = semantic_limit.saturating_add(1);
3209 let mut semantic_results = if let Some(view) = pinned_semantic_view.as_ref() {
3210 match view_semantic_search(
3211 view,
3212 project_root,
3213 &query_vector,
3214 semantic_fetch_limit,
3215 params.include_tests,
3216 ) {
3217 Ok(results) => results,
3218 Err(error) => {
3219 warnings.push(format!("view semantic read failed: {error}"));
3220 Vec::new()
3221 }
3222 }
3223 } else {
3224 match try_read_with_budget(ctx.semantic_index(), INTERACTIVE_ARTIFACT_READ_BUDGET) {
3225 Some(semantic_index) => semantic_index
3226 .as_ref()
3227 .map(|index| {
3228 index.search_filtered(&query_vector, semantic_fetch_limit, |file| {
3229 path_allowed_by_include_tests(file, project_root, params.include_tests)
3230 })
3231 })
3232 .unwrap_or_default(),
3233 None => {
3234 return semantic_unavailable_or_fallback_response(
3235 req,
3236 ctx,
3237 ¶ms,
3238 mode,
3239 &shape,
3240 "unavailable",
3241 "unavailable",
3242 format!(
3243 "Semantic search artifacts remained busy beyond {}ms.",
3244 INTERACTIVE_ARTIFACT_READ_BUDGET.as_millis()
3245 ),
3246 "artifact contention",
3247 true,
3248 warnings,
3249 project_root,
3250 top_k,
3251 page_request,
3252 extensions,
3253 engine_plan,
3254 );
3255 }
3256 }
3257 };
3258 if ctx.shared_artifacts_read_only() {
3259 semantic_results.retain(|result| result.file.is_file());
3260 }
3261 let semantic_more_available = semantic_results.len() > semantic_limit;
3262 if semantic_more_available {
3263 semantic_results.truncate(semantic_limit);
3264 }
3265 let mut engine_ranking = match run_engine_ranking(
3266 &req.id,
3267 ctx,
3268 project_root,
3269 ¶ms.query,
3270 params.include_tests,
3271 semantic_results,
3272 page_request,
3273 extensions,
3274 engine_plan,
3275 None,
3276 ) {
3277 Ok(ranking) => ranking,
3278 Err(error) => return Response::error(&req.id, "search_engine_failed", error),
3279 };
3280 if ctx.shared_artifacts_read_only() {
3281 engine_ranking
3282 .results
3283 .retain(|result| result.file.is_file());
3284 }
3285 let more_available = engine_ranking.more_available || semantic_more_available;
3286 let mut results = engine_ranking.results;
3287
3288 if mode == SearchMode::Semantic
3289 && shape.kind == QueryKind::NaturalLanguage
3290 && results.is_empty()
3291 && lexical_ready
3292 {
3293 return zero_result_escalation_response(
3294 req,
3295 ctx,
3296 ¶ms.query,
3297 &shape,
3298 mode,
3299 semantic_status,
3300 warnings,
3301 params.include_tests,
3302 project_root,
3303 project_root,
3304 serde_json::Map::new(),
3305 page_request,
3306 extensions,
3307 engine_plan,
3308 None,
3309 );
3310 }
3311
3312 let snippets_incomplete =
3320 enrich_snippets_from_source_with_context(&mut results, project_root, Some(ctx));
3321
3322 let mut text = format_semantic_text(
3323 &results,
3324 project_root,
3325 more_available,
3326 snippets_incomplete,
3327 Some(ctx),
3328 );
3329 if let Some(line) = engine_ranking.confidence_line {
3330 text.push_str("\n\n");
3331 text.push_str(line);
3332 }
3333 let mut extras = serde_json::Map::new();
3334 crate::list_surfaces::search::attach_projected_search_envelope(
3335 &mut extras,
3336 &engine_ranking.results_list_envelope,
3337 );
3338 extras.insert(
3339 "structuredContent".to_string(),
3340 engine_ranking.structured_content,
3341 );
3342
3343 search_response(
3344 req,
3345 SearchResponseParts {
3346 query: ¶ms.query,
3347 interpreted_as: interpreted_as_label(mode),
3348 query_kind: query_kind_label(shape.kind),
3349 semantic_status,
3350 status: "ready",
3351 complete: true,
3352 text,
3353 results: results.iter().map(result_to_json).collect::<Vec<_>>(),
3354 more_available,
3355 engine_capped: engine_ranking.engine_capped,
3356 fully_degraded: false,
3357 warnings,
3358 extras,
3359 },
3360 )
3361}
3362
3363struct SearchResponseParts<'a> {
3364 query: &'a str,
3365 interpreted_as: &'static str,
3366 query_kind: &'static str,
3367 semantic_status: &'static str,
3368 status: &'static str,
3369 complete: bool,
3370 text: String,
3371 results: Vec<serde_json::Value>,
3372 more_available: bool,
3373 engine_capped: bool,
3374 fully_degraded: bool,
3375 warnings: Vec<String>,
3376 extras: serde_json::Map<String, serde_json::Value>,
3377}
3378
3379impl<'a> SearchResponseParts<'a> {
3380 fn result_count(&self) -> usize {
3381 self.results.len()
3382 }
3383}
3384
3385fn zero_result_escalation_disclosure(mode: SearchMode) -> &'static str {
3386 match mode {
3387 SearchMode::Regex => "[interpreted_as: regex; no exact match — ranked by terms instead]",
3388 SearchMode::Literal => {
3389 "[interpreted_as: literal; no exact match — ranked by terms instead]"
3390 }
3391 SearchMode::Semantic => {
3392 "[interpreted_as: semantic; no result above cutoff — ranked by terms instead]"
3393 }
3394 SearchMode::Hybrid => {
3395 "[interpreted_as: hybrid; no result — no further escalation available]"
3396 }
3397 }
3398}
3399
3400fn zero_result_escalation_response(
3404 req: &RawRequest,
3405 ctx: &AppContext,
3406 query: &str,
3407 shape: &QueryShape,
3408 mode: SearchMode,
3409 semantic_status: &'static str,
3410 warnings: Vec<String>,
3411 include_tests: bool,
3412 project_root: &Path,
3413 display_root: &Path,
3414 mut extras: serde_json::Map<String, serde_json::Value>,
3415 page_request: paging::ValidatedPageRequest,
3416 extensions: &dyn extensions::SearchExtensions,
3417 base_plan: &extensions::LanePlan<'_>,
3418 borrowed_index: Option<(&SearchIndex, &GenerationToken)>,
3419) -> Response {
3420 use extensions::RawQuery;
3421
3422 let (_, facts) = extensions.classify(&RawQuery::new(query));
3423 let mut escalation_plan =
3424 extensions.plan(&SearchShape::NaturalLanguage, &facts, &base_plan.readiness);
3425 escalation_plan
3426 .selected_lanes
3427 .retain(|lane| !matches!(*lane, SearchLaneKind::Semantic | SearchLaneKind::Exact));
3428 let mut ranked = match run_engine_ranking(
3429 &req.id,
3430 ctx,
3431 project_root,
3432 query,
3433 include_tests,
3434 Vec::new(),
3435 page_request,
3436 extensions,
3437 &escalation_plan,
3438 borrowed_index,
3439 ) {
3440 Ok(ranked) => ranked,
3441 Err(error) => return Response::error(&req.id, "search_engine_failed", error),
3442 };
3443 if borrowed_index.is_some() {
3444 ranked.results.retain(|result| result.file.is_file());
3445 }
3446 let snippets_incomplete =
3447 enrich_snippets_from_source_with_context(&mut ranked.results, project_root, Some(ctx));
3448 let mut text = format_semantic_text_with_display_root(
3449 &ranked.results,
3450 display_root,
3451 ranked.more_available,
3452 snippets_incomplete,
3453 Some(ctx),
3454 );
3455 text.push('\n');
3456 text.push_str(zero_result_escalation_disclosure(mode));
3457 if let Some(line) = ranked.confidence_line {
3458 text.push_str("\n\n");
3459 text.push_str(line);
3460 }
3461 crate::list_surfaces::search::attach_projected_search_envelope(
3462 &mut extras,
3463 &ranked.results_list_envelope,
3464 );
3465 extras.insert(
3466 "zero_result_escalation".to_string(),
3467 serde_json::json!(true),
3468 );
3469 extras.insert("escalation_target".to_string(), serde_json::json!("hybrid"));
3470 extras.insert("structuredContent".to_string(), ranked.structured_content);
3471 search_response(
3472 req,
3473 SearchResponseParts {
3474 query,
3475 interpreted_as: interpreted_as_label(mode),
3476 query_kind: query_kind_label(shape.kind),
3477 semantic_status,
3478 status: "ready",
3479 complete: true,
3480 text,
3481 results: ranked
3482 .results
3483 .iter()
3484 .map(result_to_json)
3485 .collect::<Vec<_>>(),
3486 more_available: ranked.more_available,
3487 engine_capped: ranked.engine_capped,
3488 fully_degraded: false,
3489 warnings,
3490 extras,
3491 },
3492 )
3493}
3494
3495fn search_response(req: &RawRequest, parts: SearchResponseParts<'_>) -> Response {
3496 if search_cancellation_requested() {
3497 return cancelled_search_response(req);
3498 }
3499 let result_count = parts.result_count();
3500 let envelope_supplied = parts
3501 .extras
3502 .contains_key(crate::list_surfaces::search::SEARCH_WIRE_KEY);
3503 let text = if envelope_supplied {
3504 parts
3505 .text
3506 .replace(" More results available; raise topK to see more.", "")
3507 } else {
3508 parts.text
3509 };
3510 let mut object = serde_json::Map::new();
3511 object.insert("status".to_string(), serde_json::json!(parts.status));
3512 object.insert("complete".to_string(), serde_json::json!(parts.complete));
3513 object.insert("text".to_string(), serde_json::json!(text));
3514 object.insert("query".to_string(), serde_json::json!(parts.query));
3515 object.insert(
3516 "interpreted_as".to_string(),
3517 serde_json::json!(parts.interpreted_as),
3518 );
3519 object.insert(
3520 "query_kind".to_string(),
3521 serde_json::json!(parts.query_kind),
3522 );
3523 object.insert("result_count".to_string(), serde_json::json!(result_count));
3524 object.insert(
3525 "results".to_string(),
3526 serde_json::Value::Array(parts.results),
3527 );
3528 object.insert(
3529 "more_available".to_string(),
3530 serde_json::json!(parts.more_available),
3531 );
3532 object.insert(
3533 "engine_capped".to_string(),
3534 serde_json::json!(parts.engine_capped),
3535 );
3536 object.insert(
3537 "fully_degraded".to_string(),
3538 serde_json::json!(parts.fully_degraded),
3539 );
3540 object.insert(
3541 "semantic_status".to_string(),
3542 serde_json::json!(parts.semantic_status),
3543 );
3544 if !parts.warnings.is_empty() {
3545 object.insert("warnings".to_string(), serde_json::json!(parts.warnings));
3546 }
3547 for (key, value) in parts.extras {
3548 object.insert(key, value);
3549 }
3550 Response::success(&req.id, serde_json::Value::Object(object))
3551}
3552
3553fn artifact_contention_fallback_response(
3554 req: &RawRequest,
3555 ctx: &AppContext,
3556 params: &SemanticSearchParams,
3557 shape: &QueryShape,
3558 project_root: &Path,
3559 top_k: usize,
3560 artifact: &str,
3561) -> Response {
3562 semantic_unavailable_grep_fallback_response(
3563 req,
3564 ctx,
3565 params,
3566 shape,
3567 "unavailable",
3568 format!(
3569 "Search artifacts were busy beyond the {}ms interactive budget ({artifact}).",
3570 INTERACTIVE_ARTIFACT_READ_BUDGET.as_millis()
3571 ),
3572 "artifact contention",
3573 false,
3574 Vec::new(),
3575 project_root,
3576 top_k,
3577 )
3578}
3579
3580fn semantic_unavailable_or_fallback_response(
3581 req: &RawRequest,
3582 ctx: &AppContext,
3583 params: &SemanticSearchParams,
3584 mode: SearchMode,
3585 shape: &QueryShape,
3586 semantic_status: &'static str,
3587 _unavailable_status: &'static str,
3588 detail: String,
3589 footer_reason: &str,
3590 _force_lexical_fallback: bool,
3591 mut warnings: Vec<String>,
3592 project_root: &Path,
3593 top_k: usize,
3594 page_request: paging::ValidatedPageRequest,
3595 extensions: &dyn extensions::SearchExtensions,
3596 engine_plan: &extensions::LanePlan<'_>,
3597) -> Response {
3598 if engine_plan.readiness.lexical_index {
3599 let mut lexical_plan = engine_plan.clone();
3600 lexical_plan
3601 .selected_lanes
3602 .retain(|lane| *lane != SearchLaneKind::Semantic);
3603 let mut ranked = match run_engine_ranking(
3604 &req.id,
3605 ctx,
3606 project_root,
3607 ¶ms.query,
3608 params.include_tests,
3609 Vec::new(),
3610 page_request,
3611 extensions,
3612 &lexical_plan,
3613 None,
3614 ) {
3615 Ok(ranked) => ranked,
3616 Err(error) => return Response::error(&req.id, "search_engine_failed", error),
3617 };
3618 let snippets_incomplete =
3619 enrich_snippets_from_source_with_context(&mut ranked.results, project_root, Some(ctx));
3620 let mut text =
3621 format_lexical_unavailable_text(&detail, &ranked.results, project_root, footer_reason);
3622 if snippets_incomplete && !ranked.results.is_empty() {
3623 text.push_str(
3624 "\n\nSome snippets were truncated; use read or aft_zoom for full context.",
3625 );
3626 }
3627 if let Some(line) = ranked.confidence_line {
3628 text.push_str("\n\n");
3629 text.push_str(line);
3630 }
3631 warnings.push(
3632 "Semantic search unavailable; returning lexical-only fallback results.".to_string(),
3633 );
3634 let mut extras = semantic_unavailable_extras(true);
3635 crate::list_surfaces::search::attach_projected_search_envelope(
3636 &mut extras,
3637 &ranked.results_list_envelope,
3638 );
3639 extras.insert("structuredContent".to_string(), ranked.structured_content);
3640
3641 return search_response(
3642 req,
3643 SearchResponseParts {
3644 query: ¶ms.query,
3645 interpreted_as: fallback_executed_label(mode, true),
3646 query_kind: query_kind_label(shape.kind),
3647 semantic_status,
3648 status: "ready",
3649 complete: false,
3650 text,
3651 results: ranked.results.iter().map(result_to_json).collect(),
3652 more_available: ranked.more_available,
3653 engine_capped: ranked.engine_capped,
3654 fully_degraded: false,
3655 warnings,
3656 extras,
3657 },
3658 );
3659 }
3660
3661 semantic_unavailable_grep_fallback_response(
3662 req,
3663 ctx,
3664 params,
3665 shape,
3666 semantic_status,
3667 detail,
3668 footer_reason,
3669 false,
3670 warnings,
3671 project_root,
3672 top_k,
3673 )
3674}
3675
3676fn semantic_unavailable_extras(
3677 lexical_only_fallback: bool,
3678) -> serde_json::Map<String, serde_json::Value> {
3679 let mut extras = serde_json::Map::new();
3680 extras.insert("semantic_unavailable".to_string(), serde_json::json!(true));
3681 extras.insert(
3682 "lexical_only_fallback".to_string(),
3683 serde_json::json!(lexical_only_fallback),
3684 );
3685 extras
3686}
3687
3688fn semantic_unavailable_grep_fallback_response(
3689 req: &RawRequest,
3690 ctx: &AppContext,
3691 params: &SemanticSearchParams,
3692 shape: &QueryShape,
3693 semantic_status: &'static str,
3694 detail: String,
3695 footer_reason: &str,
3696 borrowed_loading: bool,
3697 mut warnings: Vec<String>,
3698 project_root: &Path,
3699 top_k: usize,
3700) -> Response {
3701 let fallback = match execute_degraded_grep_fallback(
3702 ¶ms.query,
3703 project_root,
3704 top_k,
3705 params.include_tests,
3706 &req.id,
3707 ) {
3708 Ok(result) => result,
3709 Err(response) => return response,
3710 };
3711 let result = &fallback.grep;
3712 let detail = if borrowed_loading && !result.matches.is_empty() {
3713 BORROWED_SEMANTIC_LOADING_WITH_LEXICAL_RESULTS.to_string()
3714 } else {
3715 detail
3716 };
3717 if result.fully_degraded {
3718 warnings.push(degraded_warning(ctx));
3719 }
3720 if fallback.file_cap_reached {
3721 warnings.push(format!(
3722 "Degraded grep reached its {}-file scan cap; additional files were not scanned.",
3723 fallback.file_limit
3724 ));
3725 }
3726 if fallback.walk_budget_reached {
3727 warnings.push(
3728 "Degraded grep reached its 10-second walk budget; additional files were not scanned."
3729 .to_string(),
3730 );
3731 }
3732 warnings
3733 .push("Semantic search unavailable; returning lexical-only fallback results.".to_string());
3734
3735 let result_values = result
3736 .matches
3737 .iter()
3738 .map(|grep_match| grep_match_to_json(grep_match, "literal"))
3739 .collect::<Vec<_>>();
3740 let more_available = result.truncated
3741 || result.total_matches > result.matches.len()
3742 || fallback.file_cap_reached
3743 || fallback.walk_budget_reached
3744 || result.skipped_foreign_mounts > 0;
3745 let mut extras = semantic_unavailable_extras(true);
3746 if fallback.file_cap_reached || fallback.walk_budget_reached {
3747 extras.insert(
3748 "degraded_grep_walk_truncated".to_string(),
3749 serde_json::json!(true),
3750 );
3751 }
3752 if result.skipped_foreign_mounts > 0 {
3753 extras.insert(
3754 "degraded_grep_skipped_foreign_mounts".to_string(),
3755 serde_json::json!(result.skipped_foreign_mounts),
3756 );
3757 }
3758 if fallback.file_cap_reached {
3759 extras.insert(
3760 "degraded_grep_file_limit".to_string(),
3761 serde_json::json!(fallback.file_limit),
3762 );
3763 extras.insert(
3764 "degraded_grep_candidate_files".to_string(),
3765 serde_json::json!(fallback.candidate_files),
3766 );
3767 }
3768
3769 let envelope =
3770 bounded_walk_search_envelope(result_values.len(), more_available, result.engine_capped);
3771 crate::list_surfaces::search::attach_projected_search_envelope(&mut extras, &envelope);
3772
3773 search_response(
3774 req,
3775 SearchResponseParts {
3776 query: ¶ms.query,
3777 interpreted_as: "literal",
3781 query_kind: query_kind_label(shape.kind),
3782 semantic_status,
3783 status: "ready",
3784 complete: false,
3785 text: format_grep_lexical_unavailable_text(
3786 &detail,
3787 result,
3788 project_root,
3789 footer_reason,
3790 ),
3791 results: result_values,
3792 more_available,
3793 engine_capped: result.engine_capped,
3794 fully_degraded: result.fully_degraded,
3795 warnings,
3796 extras,
3797 },
3798 )
3799}
3800
3801fn search_cut_envelope(
3802 shown: usize,
3803 more_available: bool,
3804 engine_capped: bool,
3805) -> Option<ListEnvelope> {
3806 if !more_available && !engine_capped {
3807 return None;
3808 }
3809 let mut causes = Vec::with_capacity(2);
3810 if engine_capped {
3811 causes.push(crate::list_envelope::Reason::Budget);
3812 }
3813 if more_available {
3814 causes.push(crate::list_envelope::Reason::Cap);
3815 }
3816 Some(ListEnvelope::new(
3817 shown,
3818 crate::list_envelope::Total::AtLeast(if more_available {
3819 shown.saturating_add(1)
3820 } else {
3821 shown
3822 }),
3823 crate::list_envelope::Unit::Results,
3824 causes,
3825 crate::list_surfaces::search::SEARCH_NARROW,
3826 ))
3827}
3828
3829fn bounded_walk_search_envelope(
3830 shown: usize,
3831 more_available: bool,
3832 engine_capped: bool,
3833) -> crate::list_envelope::ListEnvelope {
3834 let mut causes = vec![crate::list_envelope::Reason::Walk];
3835 if engine_capped {
3836 causes.push(crate::list_envelope::Reason::Budget);
3837 }
3838 if more_available {
3839 causes.push(crate::list_envelope::Reason::Cap);
3840 }
3841 let total = if more_available {
3842 crate::list_envelope::Total::AtLeast(shown.saturating_add(1))
3843 } else {
3844 crate::list_envelope::Total::AtLeast(shown)
3845 };
3846 crate::list_envelope::ListEnvelope::new(
3847 shown,
3848 total,
3849 crate::list_envelope::Unit::Results,
3850 causes,
3851 crate::list_surfaces::search::SEARCH_NARROW,
3852 )
3853}
3854
3855fn execute_degraded_grep_fallback(
3856 query: &str,
3857 project_root: &Path,
3858 top_k: usize,
3859 include_tests: bool,
3860 request_id: &str,
3861) -> Result<DegradedGrepFallbackResult, Response> {
3862 let compiled = match pattern_compile::compile(
3863 query,
3864 CompileOpts {
3865 literal: true,
3866 ..CompileOpts::default()
3867 },
3868 ) {
3869 CompileResult::Ok(compiled) => compiled,
3870 CompileResult::InvalidPattern { message, .. } => {
3871 return Err(Response::error_with_data(
3872 request_id,
3873 "invalid_pattern",
3874 message,
3875 serde_json::json!({"pattern": query}),
3876 ));
3877 }
3878 CompileResult::UnsupportedSyntax { feature, .. } => {
3879 return Err(Response::error_with_data(
3880 request_id,
3881 "unsupported_pattern",
3882 format!(
3883 "Pattern uses regex syntax not supported by AFT's engine: {feature}. Rewrite without {feature} or use grep for explicit regex control."
3884 ),
3885 serde_json::json!({"pattern": query, "feature": feature}),
3886 ));
3887 }
3888 };
3889
3890 let max_results = top_k.clamp(1, DEGRADED_GREP_RESULT_LIMIT);
3891 let started = Instant::now();
3892 let (files, file_cap_reached, walk_budget_reached, skipped_foreign_mounts) =
3893 collect_degraded_grep_files(project_root, include_tests, started);
3894 if search_cancellation_requested() {
3895 return Err(cancelled_search_response_from_id(request_id));
3896 }
3897 let candidate_files = files.len();
3898 let mut matches = Vec::new();
3899 let mut total_matches = 0usize;
3900 let mut files_searched = 0usize;
3901 let mut files_with_matches = 0usize;
3902 let mut truncated = false;
3903 let mut engine_capped = file_cap_reached || walk_budget_reached;
3904
3905 let read_budget_reached = AtomicBool::new(walk_budget_reached);
3906 let cancellation = crate::executor::current_job_cancellation();
3907 let mut readable_files = files
3908 .par_iter()
3909 .enumerate()
3910 .filter_map(|(index, file)| {
3911 if cancellation
3912 .as_ref()
3913 .is_some_and(|token| token.cancel_requested_before_commit())
3914 {
3915 return None;
3916 }
3917 if started.elapsed() >= DEGRADED_GREP_WALK_BUDGET {
3918 read_budget_reached.store(true, Ordering::Relaxed);
3919 return None;
3920 }
3921 crate::search_index::read_searchable_text(file)
3922 .map(|content| (index, file.clone(), content))
3923 })
3924 .collect::<Vec<_>>();
3925 if search_cancellation_requested() {
3926 return Err(cancelled_search_response_from_id(request_id));
3927 }
3928 readable_files.sort_by_key(|(index, _, _)| *index);
3931
3932 for (_, file, content) in readable_files {
3933 if search_cancellation_requested() {
3934 return Err(cancelled_search_response_from_id(request_id));
3935 }
3936 if truncated {
3937 engine_capped = true;
3938 break;
3939 }
3940
3941 files_searched += 1;
3942
3943 if search_degraded_grep_file(
3944 &file,
3945 &content,
3946 &compiled,
3947 max_results,
3948 &mut total_matches,
3949 &mut truncated,
3950 &mut matches,
3951 ) {
3952 files_with_matches += 1;
3953 }
3954 }
3955
3956 if truncated {
3957 engine_capped = true;
3958 }
3959 sort_grep_matches_by_mtime_desc(&mut matches, project_root);
3960
3961 let walk_budget_reached = read_budget_reached.load(Ordering::Relaxed);
3962 Ok(DegradedGrepFallbackResult {
3963 grep: GrepResult {
3964 matches,
3965 total_matches,
3966 files_searched,
3967 files_with_matches,
3968 index_status: IndexStatus::Fallback,
3969 truncated,
3970 fully_degraded: true,
3971 engine_capped,
3972 walk_truncated: walk_budget_reached,
3973 skipped_foreign_mounts,
3974 },
3975 file_cap_reached,
3976 file_limit: DEGRADED_GREP_FILE_LIMIT,
3977 candidate_files,
3978 walk_budget_reached,
3979 })
3980}
3981
3982fn collect_degraded_grep_files(
3983 project_root: &Path,
3984 include_tests: bool,
3985 started: Instant,
3986) -> (Vec<PathBuf>, bool, bool, usize) {
3987 let skipped_foreign_mounts = Arc::new(AtomicUsize::new(0));
3990 let boundary = crate::walk_boundary::DeviceBoundary::for_root(project_root).ok();
3991 let walker = ignore::WalkBuilder::new(project_root)
3992 .same_file_system(true)
3993 .hidden(false)
3994 .git_ignore(true)
3995 .git_global(true)
3996 .git_exclude(true)
3997 .add_custom_ignore_filename(".aftignore")
3998 .filter_entry({
3999 let skipped_foreign_mounts = Arc::clone(&skipped_foreign_mounts);
4000 move |entry| {
4001 if entry.depth() > 0
4002 && entry
4003 .file_type()
4004 .is_some_and(|file_type| file_type.is_dir())
4005 && matches!(
4006 boundary
4007 .as_ref()
4008 .map(|boundary| boundary.should_descend(entry.path())),
4009 Some(Ok(false))
4010 )
4011 {
4012 skipped_foreign_mounts.fetch_add(1, Ordering::Relaxed);
4013 return false;
4014 }
4015 let name = entry.file_name().to_string_lossy();
4016 if entry
4017 .file_type()
4018 .is_some_and(|file_type| file_type.is_dir())
4019 {
4020 return !matches!(
4021 name.as_ref(),
4022 "node_modules"
4023 | "target"
4024 | "venv"
4025 | ".venv"
4026 | ".git"
4027 | "__pycache__"
4028 | ".tox"
4029 | "dist"
4030 | "build"
4031 );
4032 }
4033 true
4034 }
4035 })
4036 .build();
4037
4038 let mut files = Vec::new();
4039 for entry in walker.filter_map(Result::ok) {
4040 if search_cancellation_requested() {
4041 return (
4042 files,
4043 false,
4044 true,
4045 skipped_foreign_mounts.load(Ordering::Relaxed),
4046 );
4047 }
4048 if started.elapsed() >= DEGRADED_GREP_WALK_BUDGET {
4049 return (
4050 files,
4051 false,
4052 true,
4053 skipped_foreign_mounts.load(Ordering::Relaxed),
4054 );
4055 }
4056 if !entry
4057 .file_type()
4058 .is_some_and(|file_type| file_type.is_file())
4059 {
4060 continue;
4061 }
4062 let path = entry.into_path();
4063 if !include_tests && path_is_hidden_test_file(&path, project_root) {
4064 continue;
4065 }
4066 if files.len() >= DEGRADED_GREP_FILE_LIMIT {
4067 return (
4068 files,
4069 true,
4070 false,
4071 skipped_foreign_mounts.load(Ordering::Relaxed),
4072 );
4073 }
4074 files.push(path);
4075 }
4076
4077 (
4078 files,
4079 false,
4080 false,
4081 skipped_foreign_mounts.load(Ordering::Relaxed),
4082 )
4083}
4084
4085fn search_degraded_grep_file(
4086 file: &Path,
4087 content: &str,
4088 compiled: &pattern_compile::CompiledPattern,
4089 max_results: usize,
4090 total_matches: &mut usize,
4091 truncated: &mut bool,
4092 matches: &mut Vec<GrepMatch>,
4093) -> bool {
4094 let line_starts = grep_executor::line_starts(content);
4095 let mut seen_lines = HashSet::new();
4096 let mut matched_this_file = false;
4097
4098 match compiled {
4099 pattern_compile::CompiledPattern::Literal(literal) => {
4100 let Some(needle) = std::str::from_utf8(&literal.needle).ok() else {
4101 return false;
4102 };
4103 let haystack = if literal.case_insensitive_ascii {
4104 Cow::Owned(content.to_ascii_lowercase())
4105 } else {
4106 Cow::Borrowed(content)
4107 };
4108
4109 for (offset, matched) in haystack.match_indices(needle) {
4110 if search_cancellation_requested() {
4111 break;
4112 }
4113 let match_text = content[offset..offset + matched.len()].to_string();
4114 let (counted, should_continue) = record_degraded_grep_match(
4115 file,
4116 content,
4117 &line_starts,
4118 &mut seen_lines,
4119 offset,
4120 match_text,
4121 max_results,
4122 total_matches,
4123 truncated,
4124 matches,
4125 );
4126 matched_this_file |= counted;
4127 if !should_continue {
4128 break;
4129 }
4130 }
4131 }
4132 pattern_compile::CompiledPattern::Regex { compiled, .. } => {
4133 for matched in compiled.find_iter(content.as_bytes()) {
4134 if search_cancellation_requested() {
4135 break;
4136 }
4137 let (counted, should_continue) = record_degraded_grep_match(
4138 file,
4139 content,
4140 &line_starts,
4141 &mut seen_lines,
4142 matched.start(),
4143 String::from_utf8_lossy(matched.as_bytes()).into_owned(),
4144 max_results,
4145 total_matches,
4146 truncated,
4147 matches,
4148 );
4149 matched_this_file |= counted;
4150 if !should_continue {
4151 break;
4152 }
4153 }
4154 }
4155 }
4156
4157 matched_this_file
4158}
4159
4160fn record_degraded_grep_match(
4161 file: &Path,
4162 content: &str,
4163 line_starts: &[usize],
4164 seen_lines: &mut HashSet<u32>,
4165 offset: usize,
4166 match_text: String,
4167 max_results: usize,
4168 total_matches: &mut usize,
4169 truncated: &mut bool,
4170 matches: &mut Vec<GrepMatch>,
4171) -> (bool, bool) {
4172 let (line, column, line_text) = grep_executor::line_details(content, line_starts, offset);
4173 if !seen_lines.insert(line) {
4174 return (false, true);
4175 }
4176
4177 *total_matches += 1;
4178 if matches.len() >= max_results {
4179 *truncated = true;
4180 return (true, false);
4181 }
4182
4183 matches.push(GrepMatch {
4184 file: file.to_path_buf(),
4185 line,
4186 column,
4187 line_text,
4188 match_text,
4189 });
4190 (true, true)
4191}
4192
4193fn semantic_index_loaded_with_budget(ctx: &AppContext) -> Result<bool, ()> {
4194 let semantic_index =
4195 try_read_with_budget(ctx.semantic_index(), INTERACTIVE_ARTIFACT_READ_BUDGET).ok_or(())?;
4196 Ok(semantic_index.is_some())
4197}
4198
4199fn search_index_ready_with_budget(
4200 ctx: &AppContext,
4201 wait_budget: Duration,
4202) -> Result<bool, SearchIndexWaitError> {
4203 let deadline = Instant::now() + wait_budget;
4204 loop {
4205 if search_cancellation_requested() {
4206 return Err(SearchIndexWaitError::Cancelled);
4207 }
4208
4209 let remaining = deadline.saturating_duration_since(Instant::now());
4210 let read_budget = remaining.min(SEARCH_INDEX_LOAD_WAIT_POLL_INTERVAL);
4211 let Some(search_index) = try_read_with_budget(ctx.search_index(), read_budget) else {
4212 if Instant::now() >= deadline {
4213 return Err(SearchIndexWaitError::Contended);
4214 }
4215 continue;
4216 };
4217 if search_index.as_ref().is_some_and(|index| index.ready) {
4218 return Ok(true);
4219 }
4220 drop(search_index);
4221
4222 let Some(search_receiver) = try_read_with_budget(ctx.search_index_rx(), read_budget) else {
4225 if Instant::now() >= deadline {
4226 return Err(SearchIndexWaitError::Contended);
4227 }
4228 continue;
4229 };
4230 let load_in_progress = search_receiver.is_some();
4231 drop(search_receiver);
4232 if !load_in_progress {
4233 return Ok(false);
4234 }
4235
4236 crate::runtime_drain::drain_search_index_events(ctx);
4237 if search_cancellation_requested() {
4238 return Err(SearchIndexWaitError::Cancelled);
4239 }
4240 let remaining = deadline.saturating_duration_since(Instant::now());
4241 if remaining.is_zero() {
4242 return Ok(false);
4243 }
4244 std::thread::sleep(remaining.min(SEARCH_INDEX_LOAD_WAIT_POLL_INTERVAL));
4245 }
4246}
4247
4248fn search_index_ready(ctx: &AppContext) -> bool {
4249 search_index_ready_with_budget(ctx, INTERACTIVE_ARTIFACT_READ_BUDGET).unwrap_or(false)
4250}
4251
4252fn embed_query(query: &str, ctx: &AppContext) -> Result<Vec<f32>, String> {
4253 let index_dimension = {
4254 let semantic_index =
4255 try_read_with_budget(ctx.semantic_index(), INTERACTIVE_ARTIFACT_READ_BUDGET)
4256 .ok_or_else(|| {
4257 format!(
4258 "semantic index remained busy beyond {}ms",
4259 INTERACTIVE_ARTIFACT_READ_BUDGET.as_millis()
4260 )
4261 })?;
4262 semantic_index
4263 .as_ref()
4264 .filter(|index| index.len() > 0)
4265 .map(|index| index.dimension())
4266 };
4267 embed_query_for_dimension(query, ctx, index_dimension)
4268}
4269
4270fn embed_query_for_dimension(
4271 query: &str,
4272 ctx: &AppContext,
4273 index_dimension: Option<usize>,
4274) -> Result<Vec<f32>, String> {
4275 let semantic_config = ctx.config().semantic.clone();
4276 let query_budget = QueryBudget::from_config(&semantic_config);
4277 let mut model_ref = ctx.semantic_embedding_model().lock();
4278
4279 if model_ref.is_none() {
4280 drop(model_ref);
4281
4282 let constructed_model = EmbeddingModel::from_config_for_query(&semantic_config)?;
4283
4284 model_ref = ctx.semantic_embedding_model().lock();
4285 if model_ref.is_none() {
4286 *model_ref = Some(constructed_model);
4287 } else {
4288 drop(model_ref);
4289 {
4290 let _discarded_model = constructed_model;
4291 }
4292 model_ref = ctx.semantic_embedding_model().lock();
4293 }
4294 }
4295
4296 let model = model_ref
4297 .as_mut()
4298 .ok_or_else(|| "embedding model was not initialized".to_string())?;
4299 let query_vector = model
4303 .embed_query_cached(query, query_budget)
4304 .map_err(|error| format!("failed to embed query: {error}"))?;
4305 drop(model_ref);
4306
4307 if let Some(index_dimension) = index_dimension {
4308 if index_dimension != query_vector.len() {
4309 return Err(format!(
4310 "semantic embedding dimension mismatch: query backend returned {}, index expects {}. Rebuild the semantic index for the active backend/model.",
4311 query_vector.len(),
4312 index_dimension
4313 ));
4314 }
4315 }
4316
4317 Ok(query_vector)
4318}
4319
4320struct ClassifiedEmbedQueryError {
4331 detail: String,
4332 footer_reason: &'static str,
4333}
4334
4335fn classify_embed_query_error(error: &str) -> ClassifiedEmbedQueryError {
4336 if let Some(timeout_ms) = query_embedding_timeout_budget(error) {
4337 let clean = strip_query_embedding_timeout_marker(error);
4338 let detail = format!(
4341 "Semantic search unavailable: query embedding timed out after {timeout_ms}ms (semantic.query_timeout_ms; raise it for slow providers). {clean}"
4342 );
4343 let footer_reason =
4344 Box::leak(format!("query embed timeout ({timeout_ms}ms)").into_boxed_str());
4345 ClassifiedEmbedQueryError {
4346 detail,
4347 footer_reason,
4348 }
4349 } else if query_embedding_is_busy(error) {
4350 let clean = strip_query_embedding_busy_marker(error);
4351 ClassifiedEmbedQueryError {
4352 detail: format!(
4353 "Semantic search unavailable: local query embedder is busy finishing an earlier inference; using lexical search only. {clean}"
4354 ),
4355 footer_reason: "query embed busy",
4356 }
4357 } else {
4358 ClassifiedEmbedQueryError {
4359 detail: format!("Semantic search unavailable: {error}"),
4360 footer_reason: "unavailable",
4361 }
4362 }
4363}
4364
4365fn rerank_semantic_candidates(results: &mut Vec<SemanticResult>, shape: &QueryShape, query: &str) {
4366 let (tokens, allow_case_fold) = semantic_rerank_tokens(query, shape);
4367 let type_concept = query_shape::is_type_concept_identifier_query(query, shape);
4368 let apply_definition_priors = shape.kind == QueryKind::NaturalLanguage || type_concept;
4369 let kind_prior_strength = semantic_kind_prior_strength(shape, apply_definition_priors);
4370
4371 for result in results.iter_mut() {
4372 result.rank_score = result.score;
4373 result.cap_protected = false;
4374 result.rank_score *= semantic_kind_multiplier(&result.kind, kind_prior_strength);
4375
4376 if !tokens.is_empty()
4377 && is_definition_kind(&result.kind)
4378 && tokens
4379 .iter()
4380 .any(|token| token_matches_candidate_name(token, result, allow_case_fold))
4381 {
4382 result.rank_score *= EXACT_NAME_DEFINITION_BOOST;
4383 if result.score >= P2_CAP_PROTECTED_COSINE_FLOOR {
4384 result.cap_protected = true;
4385 }
4386 }
4387 }
4388
4389 if apply_definition_priors {
4390 apply_natural_language_diversity_cap(results);
4391 }
4392}
4393
4394#[derive(Clone, Copy)]
4395enum SemanticKindPriorStrength {
4396 NaturalLanguage,
4397 Mixed,
4398 Inert,
4399}
4400
4401fn semantic_kind_prior_strength(
4402 shape: &QueryShape,
4403 apply_definition_priors: bool,
4404) -> SemanticKindPriorStrength {
4405 if apply_definition_priors {
4406 SemanticKindPriorStrength::NaturalLanguage
4407 } else if shape.kind == QueryKind::Mixed {
4408 SemanticKindPriorStrength::Mixed
4409 } else {
4410 SemanticKindPriorStrength::Inert
4411 }
4412}
4413
4414fn semantic_rerank_tokens(query: &str, shape: &QueryShape) -> (Vec<String>, bool) {
4415 match shape.kind {
4416 QueryKind::Identifier => (query_shape::extract_tokens(query, shape), false),
4417 QueryKind::Mixed => (query_shape::extract_tokens(query, shape), true),
4418 QueryKind::NaturalLanguage => (query_shape::extract_explicit_code_tokens(query), true),
4419 QueryKind::Path | QueryKind::ErrorCode | QueryKind::Regex => (Vec::new(), false),
4420 }
4421}
4422
4423fn semantic_kind_multiplier(kind: &SymbolKind, strength: SemanticKindPriorStrength) -> f32 {
4424 match strength {
4425 SemanticKindPriorStrength::NaturalLanguage => match kind {
4426 SymbolKind::Function
4427 | SymbolKind::Kernel
4428 | SymbolKind::Class
4429 | SymbolKind::Method
4430 | SymbolKind::Struct
4431 | SymbolKind::Interface
4432 | SymbolKind::Enum
4433 | SymbolKind::TypeAlias => 1.08,
4434 SymbolKind::Variable => 0.92,
4435 SymbolKind::FileSummary => 0.80,
4436 SymbolKind::Heading => 1.0,
4437 },
4438 SemanticKindPriorStrength::Mixed => match kind {
4439 SymbolKind::Function
4440 | SymbolKind::Kernel
4441 | SymbolKind::Class
4442 | SymbolKind::Method
4443 | SymbolKind::Struct
4444 | SymbolKind::Interface
4445 | SymbolKind::Enum
4446 | SymbolKind::TypeAlias => 1.03,
4447 SymbolKind::FileSummary => 0.90,
4448 SymbolKind::Variable | SymbolKind::Heading => 1.0,
4449 },
4450 SemanticKindPriorStrength::Inert => 1.0,
4451 }
4452}
4453
4454fn is_definition_kind(kind: &SymbolKind) -> bool {
4455 matches!(
4456 kind,
4457 SymbolKind::Function
4458 | SymbolKind::Class
4459 | SymbolKind::Method
4460 | SymbolKind::Struct
4461 | SymbolKind::Interface
4462 | SymbolKind::Enum
4463 | SymbolKind::TypeAlias
4464 )
4465}
4466
4467fn token_matches_candidate_name(
4468 token: &str,
4469 result: &SemanticResult,
4470 allow_case_fold: bool,
4471) -> bool {
4472 names_equal(token, &result.name, allow_case_fold)
4473 || result
4474 .qualified_name
4475 .as_deref()
4476 .is_some_and(|qualified_name| names_equal(token, qualified_name, allow_case_fold))
4477}
4478
4479fn names_equal(token: &str, name: &str, allow_case_fold: bool) -> bool {
4480 token == name || (allow_case_fold && token.eq_ignore_ascii_case(name))
4481}
4482
4483fn apply_natural_language_diversity_cap(results: &mut Vec<SemanticResult>) {
4484 let mut cluster_counts: HashMap<(String, SymbolKind), usize> = HashMap::new();
4485 results.retain(|result| {
4486 let key = (
4487 result
4488 .qualified_name
4489 .as_deref()
4490 .unwrap_or(&result.name)
4491 .to_string(),
4492 result.kind.clone(),
4493 );
4494 let count = cluster_counts.entry(key).or_insert(0);
4495 if *count < NATURAL_LANGUAGE_CLUSTER_CAP {
4496 *count += 1;
4497 true
4498 } else {
4499 false
4500 }
4501 });
4502}
4503
4504fn format_lexical_unavailable_text(
4505 detail: &str,
4506 results: &[HybridResult],
4507 project_root: &Path,
4508 footer_reason: &str,
4509) -> String {
4510 if results.is_empty() {
4511 return format!(
4512 "{detail}\n0 lexical matches; the semantic lane is unavailable ({footer_reason}), so prose-style queries may match only via semantic. Retry in a few seconds. [semantic: {footer_reason}]"
4513 );
4514 }
4515
4516 format!(
4517 "{detail}\nSemantic search unavailable; returning lexical-only fallback results.\n\n{}\n\nFound {} lexical fallback result(s). [semantic: {footer_reason}]",
4518 format_result_sections(results, project_root),
4519 results.len()
4520 )
4521}
4522
4523fn format_grep_lexical_unavailable_text(
4524 detail: &str,
4525 result: &GrepResult,
4526 project_root: &Path,
4527 footer_reason: &str,
4528) -> String {
4529 if result.matches.is_empty() {
4530 return format!(
4531 "{detail}\n0 lexical matches; the semantic lane is unavailable ({footer_reason}), so prose-style queries may match only via semantic. Retry in a few seconds. [semantic: {footer_reason}]"
4532 );
4533 }
4534
4535 format!(
4536 "{detail}\nSemantic search unavailable; returning lexical-only fallback results.\n\n{}\n\nFound {} lexical fallback result(s). [semantic: {footer_reason}]",
4537 crate::commands::grep::format_grep_text(result, project_root),
4538 result.matches.len()
4539 )
4540}
4541
4542fn building_lexical_note(borrowed_loading_with_results: bool) -> &'static str {
4543 if borrowed_loading_with_results {
4544 BORROWED_SEMANTIC_LOADING_WITH_LEXICAL_RESULTS
4545 } else {
4546 "Semantic index is rebuilding; results are lexical-only fallback results from the trigram index."
4547 }
4548}
4549
4550const WEAK_MATCH_COSINE_FLOOR: f32 = 0.35;
4555const P2_CAP_PROTECTED_COSINE_FLOOR: f32 = WEAK_MATCH_COSINE_FLOOR;
4556const EXACT_NAME_DEFINITION_BOOST: f32 = 1.20;
4557const NATURAL_LANGUAGE_CLUSTER_CAP: usize = 2;
4558const HIGH_CONFIDENCE_COSINE_FLOOR: f32 = 0.60;
4560
4561fn results_are_low_confidence(results: &[HybridResult]) -> bool {
4566 results
4567 .first()
4568 .and_then(|r| r.semantic_score)
4569 .is_some_and(|cosine| cosine < WEAK_MATCH_COSINE_FLOOR)
4570}
4571
4572fn format_semantic_text(
4573 results: &[HybridResult],
4574 project_root: &Path,
4575 more_available: bool,
4576 snippets_incomplete: bool,
4577 ctx: Option<&AppContext>,
4578) -> String {
4579 format_semantic_text_with_display_root(
4580 results,
4581 project_root,
4582 more_available,
4583 snippets_incomplete,
4584 ctx,
4585 )
4586}
4587
4588fn format_semantic_text_with_display_root(
4589 results: &[HybridResult],
4590 display_root: &Path,
4591 more_available: bool,
4592 snippets_incomplete: bool,
4593 ctx: Option<&AppContext>,
4594) -> String {
4595 if results.is_empty() {
4596 return "Found 0 results.".to_string();
4597 }
4598
4599 let mut text = format_result_sections_with_context(results, display_root, ctx);
4600 text.push_str(&format!("\n\nFound {} result(s).", results.len()));
4604 if more_available {
4605 text.push_str(" More results available; raise topK to see more.");
4606 }
4607 if results_are_low_confidence(results) {
4611 text.push_str("\nTop match is weak — consider rephrasing or using grep for exact terms.");
4612 }
4613 if snippets_incomplete {
4617 if ctx.map_or(true, |ctx| ctx.tool_enabled("aft_zoom")) {
4618 text.push_str("\nZoom any result for full source: aft_zoom <file> <symbol>.");
4619 } else {
4620 text.push_str("\nRead any result for full source: read <file> [startLine..endLine].");
4621 }
4622 }
4623 text
4624}
4625
4626fn format_grep_search_text(
4627 result: &GrepResult,
4628 project_root: &Path,
4629 interpreted_as: &str,
4630) -> String {
4631 let base = crate::commands::grep::format_grep_text(result, project_root);
4632 format!("{base}\n[interpreted_as: {interpreted_as}]")
4633}
4634
4635fn snippet_line_budget(global_rank: usize) -> usize {
4644 match global_rank {
4645 0 => 20,
4649 1 | 2 => 5,
4650 _ => 0,
4651 }
4652}
4653
4654#[cfg(test)]
4665fn enrich_snippets_from_source(results: &mut [HybridResult], project_root: &Path) -> bool {
4666 enrich_snippets_from_source_with_context(results, project_root, None)
4667}
4668
4669#[derive(Debug, Clone, Copy, Default)]
4670struct SnippetReadPlan {
4671 fixed_last_line: Option<usize>,
4672 summary_nonempty_lines: usize,
4673}
4674
4675impl SnippetReadPlan {
4676 fn include_through(&mut self, line: u32) {
4677 let line = line as usize;
4678 self.fixed_last_line = Some(
4679 self.fixed_last_line
4680 .map_or(line, |current| current.max(line)),
4681 );
4682 }
4683
4684 fn include_summary_lines(&mut self, count: usize) {
4685 self.summary_nonempty_lines = self.summary_nonempty_lines.max(count);
4686 }
4687
4688 fn is_satisfied(&self, line_index: usize, nonempty_lines: usize) -> bool {
4689 self.fixed_last_line
4690 .is_none_or(|last_line| line_index >= last_line)
4691 && nonempty_lines >= self.summary_nonempty_lines
4692 }
4693}
4694
4695fn snippet_read_plans(
4696 results: &[HybridResult],
4697 project_root: &Path,
4698 ctx: Option<&AppContext>,
4699) -> (HashMap<PathBuf, SnippetReadPlan>, HashMap<usize, Symbol>) {
4700 let mut plans = HashMap::<PathBuf, SnippetReadPlan>::new();
4701 let mut rank0_targets = HashMap::new();
4702
4703 for (rank, result) in results.iter().enumerate() {
4704 if result.source == "lexical" {
4705 continue;
4706 }
4707
4708 let budget = snippet_line_budget(rank);
4709 if budget == 0 {
4710 continue;
4711 }
4712
4713 let plan = plans.entry(result.file.clone()).or_default();
4714 if matches!(result.kind, SymbolKind::FileSummary) {
4715 plan.include_summary_lines(budget);
4716 continue;
4717 }
4718
4719 plan.include_through(result.end_line);
4720 if should_expand_rank0_snippet(rank, result, project_root) {
4721 let target =
4722 symbol_for_rank0_render(result, ctx).unwrap_or_else(|| symbol_from_result(result));
4723 plan.include_through(target.range.end_line);
4724 rank0_targets.insert(rank, target);
4725 }
4726 }
4727
4728 (plans, rank0_targets)
4729}
4730
4731fn read_bounded_snippet_lines(path: &Path, plan: SnippetReadPlan) -> Option<Vec<String>> {
4732 let file = fs::File::open(path).ok()?;
4733 let mut lines = Vec::new();
4734 let mut nonempty_lines = 0usize;
4735
4736 for (line_index, line) in std::io::BufReader::new(file).lines().enumerate() {
4740 let line = line.ok()?;
4741 if !line.trim().is_empty() {
4742 nonempty_lines += 1;
4743 }
4744 lines.push(line);
4745
4746 if plan.is_satisfied(line_index, nonempty_lines) {
4747 break;
4748 }
4749 }
4750
4751 Some(lines)
4752}
4753
4754fn enrich_snippets_from_source_with_context(
4755 results: &mut [HybridResult],
4756 project_root: &Path,
4757 ctx: Option<&AppContext>,
4758) -> bool {
4759 let (plans, rank0_targets) = snippet_read_plans(results, project_root, ctx);
4760 let file_lines = plans
4761 .into_iter()
4762 .map(|(path, plan)| {
4763 let lines = read_bounded_snippet_lines(&path, plan);
4764 (path, lines)
4765 })
4766 .collect::<HashMap<_, _>>();
4767 let mut incomplete = false;
4768
4769 for (rank, result) in results.iter_mut().enumerate() {
4770 if result.source == "lexical" {
4771 continue;
4772 }
4773
4774 let budget = snippet_line_budget(rank);
4775 if budget == 0 {
4776 if result.end_line >= result.start_line {
4778 incomplete = true;
4779 }
4780 result.snippet = String::new();
4781 continue;
4782 }
4783
4784 let lines = file_lines
4785 .get(&result.file)
4786 .and_then(|lines| lines.as_ref());
4787
4788 if matches!(result.kind, SymbolKind::FileSummary) {
4789 if result.exact && !result.snippet.is_empty() {
4790 continue;
4791 }
4792 if let Some(lines) = lines {
4793 result.snippet = lines
4794 .iter()
4795 .filter(|line| !line.trim().is_empty())
4796 .take(budget)
4797 .cloned()
4798 .collect::<Vec<_>>()
4799 .join("\n");
4800 }
4801 continue;
4802 }
4803
4804 let Some(lines) = lines else {
4805 result.snippet = String::new();
4807 continue;
4808 };
4809
4810 let start = (result.start_line as usize).min(lines.len());
4812 let end = ((result.end_line as usize) + 1).min(lines.len());
4813 if start >= end {
4814 result.snippet = String::new();
4815 continue;
4816 }
4817
4818 if should_expand_rank0_snippet(rank, result, project_root) {
4819 let rendered =
4820 render_rank0_symbol_snippet(result, lines, ctx, rank0_targets.get(&rank));
4821 match rendered.status {
4822 BudgetedSymbolRenderStatus::Complete => {
4823 result.snippet = append_rank0_full_symbol_notice(rendered.content);
4827 continue;
4828 }
4829 BudgetedSymbolRenderStatus::Truncated | BudgetedSymbolRenderStatus::Menu => {
4830 result.snippet = rendered.content;
4831 incomplete = true;
4832 continue;
4833 }
4834 }
4835 }
4836
4837 let range_len = end - start;
4838 let shown = range_len.min(budget);
4839 let mut snippet = lines[start..start + shown].join("\n");
4840 let remaining = range_len - shown;
4841 if remaining > 0 {
4842 snippet.push_str(&format!("\n+{remaining} more lines"));
4846 incomplete = true;
4847 }
4848 result.snippet = snippet;
4849 }
4850
4851 incomplete
4852}
4853
4854#[cfg(test)]
4855fn enrich_snippets_from_source_reference(
4856 results: &mut [HybridResult],
4857 project_root: &Path,
4858 ctx: Option<&AppContext>,
4859) -> bool {
4860 let mut file_lines: HashMap<PathBuf, Option<Vec<String>>> = HashMap::new();
4861 let mut incomplete = false;
4862
4863 for (rank, result) in results.iter_mut().enumerate() {
4864 if result.source == "lexical" {
4865 continue;
4866 }
4867
4868 let budget = snippet_line_budget(rank);
4869 if budget == 0 {
4870 if result.end_line >= result.start_line {
4871 incomplete = true;
4872 }
4873 result.snippet = String::new();
4874 continue;
4875 }
4876
4877 let lines = file_lines.entry(result.file.clone()).or_insert_with(|| {
4878 fs::read_to_string(&result.file)
4879 .ok()
4880 .map(|content| content.lines().map(str::to_string).collect())
4881 });
4882
4883 if matches!(result.kind, SymbolKind::FileSummary) {
4884 if result.exact && !result.snippet.is_empty() {
4885 continue;
4886 }
4887 if let Some(lines) = lines {
4888 result.snippet = lines
4889 .iter()
4890 .filter(|line| !line.trim().is_empty())
4891 .take(budget)
4892 .cloned()
4893 .collect::<Vec<_>>()
4894 .join("\n");
4895 }
4896 continue;
4897 }
4898
4899 let Some(lines) = lines else {
4900 result.snippet = String::new();
4901 continue;
4902 };
4903
4904 let start = (result.start_line as usize).min(lines.len());
4905 let end = ((result.end_line as usize) + 1).min(lines.len());
4906 if start >= end {
4907 result.snippet = String::new();
4908 continue;
4909 }
4910
4911 if should_expand_rank0_snippet(rank, result, project_root) {
4912 let rendered = render_rank0_symbol_snippet(result, lines, ctx, None);
4913 match rendered.status {
4914 BudgetedSymbolRenderStatus::Complete => {
4915 result.snippet = append_rank0_full_symbol_notice(rendered.content);
4916 continue;
4917 }
4918 BudgetedSymbolRenderStatus::Truncated | BudgetedSymbolRenderStatus::Menu => {
4919 result.snippet = rendered.content;
4920 incomplete = true;
4921 continue;
4922 }
4923 }
4924 }
4925
4926 let range_len = end - start;
4927 let shown = range_len.min(budget);
4928 let mut snippet = lines[start..start + shown].join("\n");
4929 let remaining = range_len - shown;
4930 if remaining > 0 {
4931 snippet.push_str(&format!("\n+{remaining} more lines"));
4932 incomplete = true;
4933 }
4934 result.snippet = snippet;
4935 }
4936
4937 incomplete
4938}
4939
4940fn render_rank0_symbol_snippet(
4941 result: &HybridResult,
4942 lines: &[String],
4943 ctx: Option<&AppContext>,
4944 planned_target: Option<&Symbol>,
4945) -> crate::commands::symbol_render::BudgetedSymbolRender {
4946 let fallback_target;
4947 let target = match planned_target {
4948 Some(target) => target,
4949 None => {
4950 fallback_target =
4951 symbol_for_rank0_render(result, ctx).unwrap_or_else(|| symbol_from_result(result));
4952 &fallback_target
4953 }
4954 };
4955 let outline = ctx.and_then(|ctx| {
4956 if might_have_container_members(target) {
4957 build_container_outline(ctx, &result.file, target).ok()
4958 } else {
4959 None
4960 }
4961 });
4962
4963 render_symbol_within_budget(
4964 target,
4965 lines,
4966 crate::parser::detect_language(&result.file),
4967 outline.as_ref(),
4968 RANK0_FULL_SNIPPET_MAX_LINES,
4969 ctx.map_or(true, |ctx| ctx.tool_enabled("aft_zoom")),
4970 )
4971}
4972
4973fn symbol_for_rank0_render(ctx_result: &HybridResult, ctx: Option<&AppContext>) -> Option<Symbol> {
4974 let symbols = ctx?.provider().list_symbols(&ctx_result.file).ok()?;
4975 symbols
4976 .iter()
4977 .find(|symbol| symbol_matches_result(symbol, ctx_result, true))
4978 .cloned()
4979 .or_else(|| {
4980 symbols
4981 .into_iter()
4982 .find(|symbol| symbol_matches_result(symbol, ctx_result, false))
4983 })
4984}
4985
4986fn symbol_matches_result(symbol: &Symbol, result: &HybridResult, exact_range: bool) -> bool {
4987 symbol.name == result.name
4988 && symbol.kind == result.kind
4989 && (!exact_range
4990 || (symbol.range.start_line == result.start_line
4991 && symbol.range.end_line == result.end_line))
4992}
4993
4994fn symbol_from_result(result: &HybridResult) -> Symbol {
4995 Symbol {
4996 name: result.name.clone(),
4997 kind: result.kind.clone(),
4998 range: Range {
4999 start_line: result.start_line,
5000 start_col: 0,
5001 end_line: result.end_line,
5002 end_col: 0,
5003 },
5004 signature: None,
5005 scope_chain: Vec::new(),
5006 exported: result.exported,
5007 parent: None,
5008 }
5009}
5010
5011fn append_rank0_full_symbol_notice(content: String) -> String {
5012 if content.is_empty() {
5013 RANK0_FULL_SYMBOL_NOTICE.to_string()
5014 } else {
5015 format!("{content}\n{RANK0_FULL_SYMBOL_NOTICE}")
5016 }
5017}
5018
5019fn should_expand_rank0_snippet(rank: usize, result: &HybridResult, project_root: &Path) -> bool {
5020 rank == 0
5021 && result
5022 .semantic_score
5023 .is_some_and(|cosine| cosine >= HIGH_CONFIDENCE_COSINE_FLOOR)
5024 && !path_is_test_support_file(&result.file, project_root)
5025}
5026
5027fn format_result_sections(results: &[HybridResult], project_root: &Path) -> String {
5028 format_result_sections_with_context(results, project_root, None)
5029}
5030
5031fn format_result_sections_with_context(
5032 results: &[HybridResult],
5033 project_root: &Path,
5034 ctx: Option<&AppContext>,
5035) -> String {
5036 let annotations = ctx
5042 .map(|ctx| blast_radius_annotations(ctx, results))
5043 .unwrap_or_else(|| vec![None; results.len()]);
5044 let mut group_order: Vec<String> = Vec::new();
5045 let mut groups: HashMap<String, Vec<(usize, &HybridResult)>> = HashMap::new();
5046
5047 for (index, result) in results.iter().enumerate() {
5048 let display_path = result
5049 .file
5050 .strip_prefix(project_root)
5051 .unwrap_or(&result.file)
5052 .display()
5053 .to_string();
5054 if !groups.contains_key(&display_path) {
5055 group_order.push(display_path.clone());
5056 }
5057 groups
5058 .entry(display_path)
5059 .or_default()
5060 .push((index, result));
5061 }
5062
5063 group_order
5064 .iter()
5065 .map(|file| {
5066 let matching_line = groups[file].iter().find(|(_, result)| {
5067 matches!(result.kind, SymbolKind::FileSummary)
5068 && (result.exact || result.source == "lexical")
5069 && !result.snippet.trim().is_empty()
5070 });
5071 let mut section = matching_line.map_or_else(
5072 || file.clone(),
5073 |(_, result)| format!("{file}:{}", display_line_number(result.start_line)),
5074 );
5075 if groups[file].iter().any(|(_, result)| result.exact) {
5076 section.push_str(" [exact]");
5077 }
5078 if matching_line.is_some_and(|(_, result)| result.source == "lexical") {
5079 section.push_str(" [lexical match]");
5080 }
5081 if let Some((_, result)) = matching_line {
5082 for line in result.snippet.lines() {
5083 section.push_str("\n ");
5084 section.push_str(line);
5085 }
5086 }
5087
5088 for (index, result) in &groups[file] {
5094 if matching_line.is_some_and(|(matching_index, _)| matching_index == index) {
5095 continue;
5096 }
5097 if result.source == "lexical" {
5098 section.push_str(" [lexical match]");
5100 continue;
5101 }
5102 if matches!(result.kind, SymbolKind::FileSummary) {
5103 section.push_str(&format!("\n {} [file summary]", result.name));
5104 } else {
5105 section.push_str(&format!(
5106 "\n {} [{}] lines {}-{}{}",
5107 result.name,
5108 symbol_kind_label(&result.kind),
5109 display_line_number(result.start_line),
5110 display_line_number(result.end_line),
5111 annotations
5112 .get(*index)
5113 .and_then(|annotation| annotation.as_deref())
5114 .unwrap_or("")
5115 ));
5116 }
5117 if !result.snippet.trim().is_empty() {
5118 for line in result.snippet.lines() {
5119 section.push_str("\n ");
5120 section.push_str(line);
5121 }
5122 }
5123 }
5124
5125 section
5126 })
5127 .collect::<Vec<_>>()
5128 .join("\n\n")
5129}
5130
5131fn blast_radius_annotations(ctx: &AppContext, results: &[HybridResult]) -> Vec<Option<String>> {
5132 let Some(store) = warm_callgraph_store(ctx) else {
5133 return vec![None; results.len()];
5134 };
5135
5136 results
5137 .iter()
5138 .map(|result| blast_radius_annotation_for_result(&store, result))
5139 .collect()
5140}
5141
5142fn warm_callgraph_store(
5143 ctx: &AppContext,
5144) -> Option<std::sync::Arc<crate::callgraph_store::ReadonlyCallGraphStore>> {
5145 let receiver = ctx.callgraph_store_rx().try_lock()?;
5146 if receiver.is_some() {
5147 return None;
5148 }
5149 drop(receiver);
5150 try_read_with_budget(ctx.callgraph_store(), INTERACTIVE_ARTIFACT_READ_BUDGET)
5151 .and_then(|store| store.as_ref().map(std::sync::Arc::clone))
5152}
5153
5154fn blast_radius_annotation_for_result(
5155 store: &crate::callgraph_store::ReadonlyCallGraphStore,
5156 result: &HybridResult,
5157) -> Option<String> {
5158 if result.source == "lexical" || matches!(result.kind, SymbolKind::FileSummary) {
5159 return None;
5160 }
5161 if result.name.trim().is_empty() {
5162 return None;
5163 }
5164
5165 let callers = callers_result(store, &result.file, &result.name, 1, true).ok()?;
5166 let mut caller_basenames = Vec::new();
5167 let mut seen_files = HashSet::new();
5168 for group in &callers.callers {
5169 if seen_files.insert(group.file.clone()) {
5170 caller_basenames.push(compact_caller_basename(&group.file));
5171 }
5172 }
5173
5174 let mut suffix = format!(" ↩{}", callers.total_callers);
5175 if !caller_basenames.is_empty() {
5176 let more = caller_basenames.len() > 2;
5177 let names = caller_basenames
5178 .iter()
5179 .take(2)
5180 .cloned()
5181 .collect::<Vec<_>>()
5182 .join(",");
5183 suffix.push(' ');
5184 suffix.push_str(&names);
5185 if more {
5186 suffix.push_str(",…");
5187 }
5188 }
5189 Some(suffix)
5190}
5191
5192fn compact_caller_basename(file: &str) -> String {
5193 let basename = Path::new(file)
5194 .file_name()
5195 .and_then(|name| name.to_str())
5196 .unwrap_or(file);
5197 truncate_chars(basename, 18)
5198}
5199
5200fn truncate_chars(value: &str, max_chars: usize) -> String {
5201 let mut chars = value.chars();
5202 let truncated = chars.by_ref().take(max_chars).collect::<String>();
5203 if chars.next().is_some() {
5204 format!("{truncated}…")
5205 } else {
5206 truncated
5207 }
5208}
5209
5210fn result_to_json(result: &HybridResult) -> serde_json::Value {
5211 let is_file_level = matches!(result.kind, SymbolKind::FileSummary);
5212 let is_matching_line = is_file_level
5213 && (result.exact || result.source == "lexical")
5214 && !result.snippet.trim().is_empty();
5215 let (start_line, end_line) = if is_file_level && !is_matching_line {
5216 (serde_json::Value::Null, serde_json::Value::Null)
5217 } else {
5218 (
5219 serde_json::json!(display_line_number(result.start_line)),
5220 serde_json::json!(display_line_number(result.end_line)),
5221 )
5222 };
5223
5224 serde_json::json!({
5225 "file": result.file.display().to_string(),
5226 "name": result.name,
5227 "kind": result.kind,
5228 "start_line": start_line,
5229 "end_line": end_line,
5230 "location": if is_matching_line { "matching line" } else if is_file_level { "[file summary]" } else { "line range" },
5231 "score": result.score,
5232 "source": result.source,
5233 "semantic_score": result.semantic_score,
5234 "lexical_score": result.lexical_score,
5235 "hybrid_boosted": result.hybrid_boosted,
5236 "exact": result.exact,
5237 "snippet": result.snippet,
5238 })
5239}
5240
5241fn grep_match_to_json(grep_match: &GrepMatch, source: &'static str) -> serde_json::Value {
5242 serde_json::json!({
5243 "kind": "GrepLine",
5244 "source": source,
5245 "file": grep_match.file.display().to_string(),
5246 "line": grep_match.line,
5247 "column": grep_match.column,
5248 "line_text": grep_match.line_text,
5249 "match_text": grep_match.match_text,
5250 })
5251}
5252
5253fn display_line_number(line: u32) -> u32 {
5254 line.saturating_add(1)
5255}
5256
5257fn symbol_kind_label(kind: &SymbolKind) -> &'static str {
5258 match kind {
5259 SymbolKind::Function => "function",
5260 SymbolKind::Kernel => "kernel",
5261 SymbolKind::Class => "class",
5262 SymbolKind::Method => "method",
5263 SymbolKind::Struct => "struct",
5264 SymbolKind::Interface => "interface",
5265 SymbolKind::Enum => "enum",
5266 SymbolKind::TypeAlias => "type_alias",
5267 SymbolKind::Variable => "variable",
5268 SymbolKind::Heading => "heading",
5269 SymbolKind::FileSummary => "file-summary",
5270 }
5271}
5272
5273fn semantic_status_label(status: &SemanticIndexStatus) -> &'static str {
5274 match status {
5275 SemanticIndexStatus::Ready { .. } => "ready",
5276 SemanticIndexStatus::Building { .. } => "building",
5277 SemanticIndexStatus::Disabled => "disabled",
5278 SemanticIndexStatus::Failed(_) => "unavailable",
5279 }
5280}
5281
5282fn interpreted_as_label(mode: SearchMode) -> &'static str {
5283 match mode {
5284 SearchMode::Regex => "regex",
5285 SearchMode::Literal => "literal",
5286 SearchMode::Semantic => "semantic",
5287 SearchMode::Hybrid => "hybrid",
5288 }
5289}
5290
5291fn fallback_executed_label(mode: SearchMode, lexical_ran: bool) -> &'static str {
5300 if lexical_ran {
5301 "lexical"
5302 } else {
5303 interpreted_as_label(mode)
5304 }
5305}
5306
5307fn query_kind_label(kind: QueryKind) -> &'static str {
5308 match kind {
5309 QueryKind::Identifier => "Identifier",
5310 QueryKind::Mixed => "Mixed",
5311 QueryKind::ErrorCode => "ErrorCode",
5312 QueryKind::Path => "Path",
5313 QueryKind::Regex => "Regex",
5314 QueryKind::NaturalLanguage => "NaturalLanguage",
5315 }
5316}
5317
5318fn strip_surrounding_quotes(query: String) -> String {
5322 let trimmed = query.trim();
5323 if trimmed.len() < 2 {
5324 return query;
5325 }
5326 let first = trimmed.chars().next().unwrap();
5327 let last = trimmed.chars().next_back().unwrap();
5328 if matches!(first, '"' | '\'' | '`') && first == last {
5329 let mut chars = trimmed.chars();
5330 chars.next();
5331 chars.next_back();
5332 return chars.as_str().to_string();
5333 }
5334 query
5335}
5336
5337fn extracted_tokens_all_short(query: &str, shape: &QueryShape) -> bool {
5338 let tokens = query_shape::extract_tokens(query, shape);
5339 !tokens.is_empty() && tokens.iter().all(|token| token.len() < 3)
5340}
5341
5342pub fn humanize_degraded_reasons(reasons: &[String]) -> Vec<String> {
5343 reasons.iter().map(|code| humanize_one(code)).collect()
5344}
5345
5346fn humanize_one(code: &str) -> String {
5347 if code == "home_root" {
5348 return "Project root is set to your home directory; large file-system indexes are disabled to avoid scanning the whole home tree.".into();
5349 }
5350 if code == "watcher_unavailable" {
5351 return "file watcher unavailable; continuing without live external-change invalidation"
5352 .to_string();
5353 }
5354 format!("(Degraded: {})", code)
5355}
5356
5357fn degraded_warning(ctx: &AppContext) -> String {
5358 let mut text = "Lexical search ran in degraded full-file-scan mode.".to_string();
5359 let reasons = ctx.degraded_reasons();
5360 if !reasons.is_empty() {
5361 text.push_str(" Reasons: ");
5362 text.push_str(&humanize_degraded_reasons(&reasons).join("; "));
5363 }
5364 text
5365}
5366
5367#[cfg(test)]
5368mod tests {
5369 use super::*;
5370 use crate::callgraph::walk_project_files;
5371 use crate::callgraph_store::CallGraphStore;
5372 use crate::config::{Config, SemanticBackend, SemanticBackendConfig};
5373 use crate::context::{
5374 callgraph_cold_build_spawn_count_for_test, reset_callgraph_cold_build_spawn_count_for_test,
5375 AppContext,
5376 };
5377 use crate::parser::TreeSitterProvider;
5378 use crate::semantic_index::{
5379 with_query_budget_for_test, LocalEmbeddingProvider, SemanticEmbeddingModel, SemanticIndex,
5380 };
5381 use serde_json::Value;
5382 use std::io::{Read, Write};
5383 use std::net::TcpListener;
5384 use std::path::{Path, PathBuf};
5385 use std::sync::atomic::{AtomicUsize, Ordering};
5386 use std::sync::{Arc, Condvar, Mutex};
5387 use std::thread;
5388 use std::time::Duration;
5389
5390 fn semantic_request(query: &str, top_k: usize) -> RawRequest {
5391 serde_json::from_value(serde_json::json!({
5392 "id": "semantic-search-test",
5393 "command": "semantic_search",
5394 "query": query,
5395 "top_k": top_k,
5396 }))
5397 .expect("build semantic search request")
5398 }
5399
5400 fn semantic_request_with_hint(query: &str, top_k: usize, hint: &str) -> RawRequest {
5401 serde_json::from_value(serde_json::json!({
5402 "id": "semantic-search-test",
5403 "command": "semantic_search",
5404 "query": query,
5405 "top_k": top_k,
5406 "hint": hint,
5407 }))
5408 .expect("build semantic search request")
5409 }
5410
5411 fn semantic_page_request(query: &str, top_k: usize, offset: usize) -> RawRequest {
5412 serde_json::from_value(serde_json::json!({
5413 "id": "semantic-search-page-test",
5414 "command": "semantic_search",
5415 "query": query,
5416 "top_k": top_k,
5417 "offset": offset,
5418 "hint": "literal",
5419 }))
5420 .expect("build paged semantic search request")
5421 }
5422
5423 fn response_value(response: Response) -> serde_json::Value {
5424 serde_json::to_value(response).expect("serialize response")
5425 }
5426
5427 fn test_context(project_root: &Path) -> AppContext {
5428 AppContext::new(
5429 Box::new(TreeSitterProvider::new()),
5430 Config {
5431 project_root: Some(project_root.to_path_buf()),
5432 ..Config::default()
5433 },
5434 )
5435 }
5436
5437 fn install_warm_callgraph_store(ctx: &AppContext, project_root: &Path) {
5438 let root = std::fs::canonicalize(project_root).expect("canonical project root");
5439 let files = walk_project_files(&root).collect::<Vec<_>>();
5440 let store_dir = root.join(".callgraph-store-test");
5441 let store =
5442 CallGraphStore::open(store_dir.clone(), root.clone()).expect("open callgraph store");
5443 store.cold_build(&files).expect("build callgraph store");
5444 drop(store);
5445 let store = CallGraphStore::open_readonly(store_dir, root)
5446 .expect("open read-only callgraph store")
5447 .expect("ready callgraph store");
5448 *ctx.callgraph_store()
5449 .write()
5450 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(store));
5451 }
5452
5453 struct BlockingLocalProvider {
5454 calls: Arc<AtomicUsize>,
5455 started: std::sync::mpsc::Sender<()>,
5456 gate: Arc<(Mutex<bool>, Condvar)>,
5457 }
5458
5459 impl LocalEmbeddingProvider for BlockingLocalProvider {
5460 fn embed(&mut self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
5461 self.calls.fetch_add(1, Ordering::SeqCst);
5462 let _ = self.started.send(());
5463 let (released, wake) = &*self.gate;
5464 let mut released = released
5465 .lock()
5466 .unwrap_or_else(std::sync::PoisonError::into_inner);
5467 while !*released {
5468 released = wake
5469 .wait(released)
5470 .unwrap_or_else(std::sync::PoisonError::into_inner);
5471 }
5472 Ok(vec![vec![0.1, 0.2, 0.3]; texts.len()])
5473 }
5474 }
5475
5476 fn start_mock_embedding_server() -> (String, thread::JoinHandle<()>) {
5477 let listener = TcpListener::bind("127.0.0.1:0").expect("bind embedding server");
5478 let addr = listener.local_addr().expect("embedding server addr");
5479 let handle = thread::spawn(move || {
5480 let (mut stream, _) = listener.accept().expect("accept embedding request");
5481 let mut buf = Vec::new();
5482 let mut chunk = [0u8; 4096];
5483 let mut header_end = None;
5484 let mut content_length = 0usize;
5485 loop {
5486 let n = stream.read(&mut chunk).expect("read embedding request");
5487 if n == 0 {
5488 break;
5489 }
5490 buf.extend_from_slice(&chunk[..n]);
5491 if header_end.is_none() {
5492 if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
5493 header_end = Some(pos + 4);
5494 for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
5495 if let Some(value) = line.strip_prefix("Content-Length:") {
5496 content_length = value.trim().parse::<usize>().unwrap_or(0);
5497 }
5498 }
5499 }
5500 }
5501 if let Some(end) = header_end {
5502 if buf.len() >= end + content_length {
5503 break;
5504 }
5505 }
5506 }
5507
5508 let body = r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
5509 let response = format!(
5510 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5511 body.len(),
5512 body
5513 );
5514 stream
5515 .write_all(response.as_bytes())
5516 .expect("write embedding response");
5517 });
5518
5519 (format!("http://{}", addr), handle)
5520 }
5521
5522 #[test]
5523 fn embed_query_construction_error_leaves_slot_empty_for_retry() {
5524 let project = tempfile::tempdir().expect("create project dir");
5525 let ctx = AppContext::new(
5526 Box::new(TreeSitterProvider::new()),
5527 Config {
5528 project_root: Some(project.path().to_path_buf()),
5529 semantic: SemanticBackendConfig {
5530 backend: SemanticBackend::OpenAiCompatible,
5531 model: "test-embedding".to_string(),
5532 base_url: None,
5533 api_key_env: None,
5534 timeout_ms: 5_000,
5535 query_timeout_ms: 3_000,
5536 max_batch_size: 64,
5537 max_files: 20_000,
5538 ..Default::default()
5539 },
5540 ..Config::default()
5541 },
5542 );
5543
5544 let err = embed_query("anything", &ctx).expect_err("construction should fail");
5545 assert!(
5546 err.contains("base_url is required"),
5547 "expected missing base_url construction error, got: {err}"
5548 );
5549 assert!(
5550 ctx.semantic_embedding_model().lock().is_none(),
5551 "failed model construction must not poison the lazy slot"
5552 );
5553
5554 let (base_url, handle) = start_mock_embedding_server();
5555 ctx.update_config(|config| {
5556 config.semantic.base_url = Some(base_url);
5557 });
5558
5559 let vector = embed_query("anything", &ctx).expect("retry should construct and embed");
5560 assert_eq!(vector, vec![0.1, 0.2, 0.3]);
5561 assert!(
5562 ctx.semantic_embedding_model().lock().is_some(),
5563 "successful retry should install the constructed model"
5564 );
5565 handle.join().expect("embedding server thread");
5566 }
5567
5568 #[test]
5569 fn blocked_local_query_times_out_falls_back_and_late_result_populates_cache() {
5570 let project = tempfile::tempdir().expect("create project dir");
5571 std::fs::write(
5572 project.path().join("remix-panel.ts"),
5573 "// where remix panel mounted option switching handled\nexport const panel = true;\n",
5574 )
5575 .expect("write lexical fallback fixture");
5576 let ctx = test_context(project.path());
5577 *ctx.semantic_index_status()
5578 .write()
5579 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
5580 *ctx.semantic_index()
5581 .write()
5582 .unwrap_or_else(std::sync::PoisonError::into_inner) =
5583 Some(SemanticIndex::new(project.path().to_path_buf(), 3));
5584
5585 let calls = Arc::new(AtomicUsize::new(0));
5586 let gate = Arc::new((Mutex::new(false), Condvar::new()));
5587 let (started_tx, started_rx) = std::sync::mpsc::channel();
5588 *ctx.semantic_embedding_model().lock() =
5589 Some(SemanticEmbeddingModel::from_local_provider_for_test(
5590 Box::new(BlockingLocalProvider {
5591 calls: Arc::clone(&calls),
5592 started: started_tx,
5593 gate: Arc::clone(&gate),
5594 }),
5595 project.path().to_path_buf(),
5596 ));
5597
5598 let query = "where remix panel mounted option switching handled";
5599 let started_at = Instant::now();
5600 let timeout_response = with_query_budget_for_test(200, || {
5601 response_value(handle_semantic_search(&semantic_request(query, 5), &ctx))
5602 });
5603 assert!(
5604 started_at.elapsed() < Duration::from_secs(1),
5605 "local query timeout must bound the search request"
5606 );
5607 started_rx
5608 .recv_timeout(Duration::from_secs(1))
5609 .expect("local embed call started");
5610 assert_eq!(timeout_response["success"], true);
5611 assert_eq!(timeout_response["lexical_only_fallback"], true);
5612 let timeout_text = timeout_response["text"]
5613 .as_str()
5614 .expect("timeout fallback text");
5615 assert!(timeout_text.contains("query embedding timed out after 200ms"));
5616 assert!(timeout_text.contains("semantic.query_timeout_ms"));
5617 assert!(timeout_text.contains("[semantic: query embed timeout (200ms)]"));
5618 assert!(timeout_response["warnings"]
5619 .as_array()
5620 .expect("timeout warnings")
5621 .iter()
5622 .any(|warning| warning
5623 .as_str()
5624 .is_some_and(|warning| warning.contains("lexical-only fallback"))));
5625
5626 let busy_request_id = "semantic-search-test";
5627 let busy_started = Instant::now();
5628 let busy_response = with_query_budget_for_test(200, || {
5629 response_value(handle_semantic_search(
5630 &semantic_request("how remix panel option state changes", 5),
5631 &ctx,
5632 ))
5633 });
5634 assert!(
5635 busy_started.elapsed() < Duration::from_millis(200),
5636 "a new query must not queue behind the late inference"
5637 );
5638 assert_eq!(busy_response["success"], true);
5639 assert_eq!(busy_response["lexical_only_fallback"], true);
5640 assert!(busy_response["text"]
5641 .as_str()
5642 .expect("busy fallback text")
5643 .contains("busy finishing an earlier inference"));
5644 assert_eq!(
5645 crate::search_b2::embed_counter::read(busy_request_id),
5646 crate::search_b2::embed_counter::EmbedCounts {
5647 requested: 1,
5648 cache_hits: 0,
5649 live_calls: 0,
5650 }
5651 );
5652 assert_eq!(calls.load(Ordering::SeqCst), 1);
5653
5654 let (released, wake) = &*gate;
5655 *released
5656 .lock()
5657 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
5658 wake.notify_all();
5659 let cache_deadline = Instant::now() + Duration::from_secs(1);
5660 loop {
5661 let cache_len = ctx
5662 .semantic_embedding_model()
5663 .lock()
5664 .as_ref()
5665 .expect("installed local model")
5666 .query_embedding_cache_stats()
5667 .2;
5668 if cache_len == 1 {
5669 break;
5670 }
5671 assert!(
5672 Instant::now() < cache_deadline,
5673 "late local query vector did not reach the cache"
5674 );
5675 thread::sleep(Duration::from_millis(5));
5676 }
5677
5678 let request_id = "local-query-late-cache-hit";
5679 let _counter = crate::search_b2::embed_counter::install(request_id);
5680 let cached_vector = with_query_budget_for_test(200, || embed_query(query, &ctx))
5681 .expect("identical query reads late vector from cache");
5682 assert_eq!(cached_vector, vec![0.1, 0.2, 0.3]);
5683 assert_eq!(
5684 crate::search_b2::embed_counter::read(request_id),
5685 crate::search_b2::embed_counter::EmbedCounts {
5686 requested: 0,
5687 cache_hits: 1,
5688 live_calls: 0,
5689 }
5690 );
5691 assert_eq!(calls.load(Ordering::SeqCst), 1);
5692 }
5693
5694 #[test]
5695 fn classify_embed_query_error_names_timeout_budget_and_knob() {
5696 let timeout_error = format!(
5701 "failed to embed query: {}openai compatible request failed: operation timed out",
5702 crate::semantic_index::query_embedding_timeout_marker(3_000)
5703 );
5704 let classified = classify_embed_query_error(&timeout_error);
5705 assert!(
5706 classified.detail.contains("timed out after 3000ms"),
5707 "timeout detail must name the budget: {}",
5708 classified.detail
5709 );
5710 assert!(
5711 classified.detail.contains("semantic.query_timeout_ms"),
5712 "timeout detail must name the knob: {}",
5713 classified.detail
5714 );
5715 assert!(
5716 classified.detail.contains("raise it for slow providers"),
5717 "timeout detail must name the remedy: {}",
5718 classified.detail
5719 );
5720 assert_eq!(classified.footer_reason, "query embed timeout (3000ms)");
5721 }
5722
5723 #[test]
5724 fn classify_embed_query_error_non_timeout_keeps_current_shape() {
5725 for non_timeout in [
5729 "failed to embed query: openai compatible request failed (HTTP 401): Unauthorized",
5730 "failed to embed query: openai compatible request failed: connection refused",
5731 "semantic embedding dimension mismatch: query backend returned 768, index expects 384",
5732 ] {
5733 let classified = classify_embed_query_error(non_timeout);
5734 assert!(
5735 !classified.detail.contains("timed out"),
5736 "non-timeout must not claim timeout: {}",
5737 classified.detail
5738 );
5739 assert!(
5740 !classified.detail.contains("query_timeout_ms"),
5741 "non-timeout must not name the knob: {}",
5742 classified.detail
5743 );
5744 assert_eq!(classified.footer_reason, "unavailable");
5745 assert!(
5746 classified
5747 .detail
5748 .starts_with("Semantic search unavailable: "),
5749 "non-timeout keeps current message shape: {}",
5750 classified.detail
5751 );
5752 }
5753 }
5754
5755 #[test]
5756 fn external_readiness_reports_building_before_bounded_borrow() {
5757 let project = tempfile::tempdir().expect("create project dir");
5758 let ctx = test_context(project.path());
5759 let source = ExternalReadinessSource::new(&ctx, project.path(), None);
5760
5761 let observation = extensions::ReadinessSource::sample(&source);
5762
5763 assert_eq!(observation.trigram.status, IndexStatus::Building);
5764 assert!(matches!(
5765 observation.semantic.status,
5766 SemanticIndexStatus::Building { ref stage, .. } if stage == "loading_artifacts"
5767 ));
5768 }
5769
5770 #[test]
5771 fn short_nl_concept_routes_to_hybrid_when_lexical_ready() {
5772 let shape = query_shape::classify("parse imports");
5776 assert_eq!(shape.kind, QueryKind::NaturalLanguage);
5777 let mut warnings = Vec::new();
5778 let mode = choose_mode("parse imports", &shape, true, &mut warnings);
5779 assert_eq!(mode, SearchMode::Hybrid);
5780 }
5781
5782 #[test]
5783 fn long_nl_phrase_runs_both_ready_lanes() {
5784 let q = "how does the bridge resolve the binary";
5785 let shape = query_shape::classify(q);
5786 assert_eq!(shape.kind, QueryKind::NaturalLanguage);
5787 let mut warnings = Vec::new();
5788 let mode = choose_mode(q, &shape, true, &mut warnings);
5789 assert_eq!(mode, SearchMode::Hybrid);
5790 assert!(warnings.is_empty());
5791 }
5792
5793 #[test]
5794 fn long_nl_phrase_discloses_semantic_only_when_lexical_is_unavailable() {
5795 let q = "how does the bridge resolve the binary";
5796 let shape = query_shape::classify(q);
5797 let mut warnings = Vec::new();
5798 let mode = choose_mode(q, &shape, false, &mut warnings);
5799 assert_eq!(mode, SearchMode::Semantic);
5800 assert_eq!(
5801 warnings,
5802 ["Lexical trigram index is unavailable; using semantic search only."]
5803 );
5804 }
5805
5806 #[test]
5807 fn short_nl_extracts_lexical_tokens() {
5808 let tokens = query_shape::extract_short_nl_lexical_tokens("parse imports");
5811 assert_eq!(tokens, vec!["parse".to_string(), "imports".to_string()]);
5812 let tokens2 = query_shape::extract_short_nl_lexical_tokens("go to");
5814 assert!(tokens2.is_empty());
5815 }
5816
5817 #[test]
5818 fn long_nl_lexical_tokens_drop_stopwords_and_normalize_punctuation() {
5819 let shape = query_shape::classify("not wired into the built-in browser tool yet");
5820 assert_eq!(
5821 query_shape::extract_lexical_tokens(
5822 "not wired into the built-in browser tool yet",
5823 &shape,
5824 ),
5825 ["wired", "built", "browser", "tool", "yet"]
5826 );
5827 }
5828
5829 #[test]
5830 fn exact_tiers_normalize_phrases_and_bound_token_windows() {
5831 let project = tempfile::tempdir().expect("create project dir");
5832 let phrase = project.path().join("phrase.rs");
5833 let window = project.path().join("window.rs");
5834 let scattered = project.path().join("scattered.rs");
5835 fs::write(&phrase, "// ALPHA beta gamma\n").expect("write phrase fixture");
5836 fs::write(&window, "// gamma\n// alpha\n// beta\n").expect("write window fixture");
5837 fs::write(
5838 &scattered,
5839 "// gamma\n// filler\n// filler\n// alpha\n// beta\n",
5840 )
5841 .expect("write scattered fixture");
5842 let tokens = query_shape::extract_content_tokens("alpha beta gamma");
5843
5844 assert_eq!(
5845 lexical_candidate_exactness(&phrase, "alpha beta gamma", &tokens),
5846 (true, 1, Some(1))
5847 );
5848 assert_eq!(
5849 lexical_candidate_exactness(&window, "alpha beta gamma", &tokens),
5850 (true, 0, Some(3))
5851 );
5852 assert_eq!(
5853 lexical_candidate_exactness(&scattered, "alpha beta gamma", &tokens),
5854 (false, 0, None)
5855 );
5856 }
5857
5858 #[test]
5859 fn building_status_returns_index_backed_fallback_results() {
5860 let project = tempfile::tempdir().expect("create project dir");
5861 let source_file = project.path().join("src/lib.rs");
5862 std::fs::create_dir_all(source_file.parent().expect("source parent"))
5863 .expect("create source dir");
5864 let source = "pub fn needle_symbol() -> bool { true }\n";
5865 std::fs::write(&source_file, source).expect("write source file");
5866
5867 let ctx = test_context(project.path());
5868 let mut index = SearchIndex::new();
5869 index.index_file(&source_file, source.as_bytes());
5870 index.ready = true;
5871 *ctx.search_index()
5872 .write()
5873 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
5874 *ctx.semantic_index_status()
5875 .write()
5876 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
5877 stage: "embedding".to_string(),
5878 files: Some(1),
5879 entries_done: Some(0),
5880 entries_total: Some(1),
5881 };
5882
5883 let response = response_value(handle_semantic_search(
5884 &semantic_request("needle_symbol", 5),
5885 &ctx,
5886 ));
5887
5888 assert_eq!(response["success"], true);
5889 assert_eq!(response["status"], "building");
5890 assert_eq!(response["semantic_status"], "building");
5891 assert_eq!(response["interpreted_as"], "lexical");
5895 assert!(response["note"]
5896 .as_str()
5897 .expect("note")
5898 .contains("lexical-only fallback"));
5899 let text = response["text"].as_str().expect("text");
5900 assert!(text.contains("lexical fallback"));
5901 assert!(text.contains("Semantic index is rebuilding"));
5902 assert!(!text.contains(BORROWED_SEMANTIC_LOADING_WITH_LEXICAL_RESULTS));
5903 let results = response["results"].as_array().expect("results array");
5904 assert!(
5905 results.iter().any(|result| {
5906 matches!(result["source"].as_str(), Some("exact" | "lexical"))
5907 && result["file"]
5908 .as_str()
5909 .expect("file")
5910 .ends_with("src/lib.rs")
5911 }),
5912 "expected index-backed fallback result, got {results:?}"
5913 );
5914 }
5915
5916 #[test]
5917 fn borrowed_loading_with_lexical_results_describes_shared_lane_truthfully() {
5918 let project = tempfile::tempdir().expect("create project dir");
5919 let source_file = project.path().join("src/lib.rs");
5920 std::fs::create_dir_all(source_file.parent().expect("source parent"))
5921 .expect("create source dir");
5922 let source = "pub fn borrowed_loading_needle() {}\n";
5923 std::fs::write(&source_file, source).expect("write source file");
5924
5925 let ctx = test_context(project.path());
5926 ctx.set_cache_role(true, None);
5927 let mut index = SearchIndex::new();
5928 index.index_file(&source_file, source.as_bytes());
5929 index.ready = true;
5930 *ctx.search_index()
5931 .write()
5932 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
5933 *ctx.semantic_index_status()
5934 .write()
5935 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
5936 stage: "loading_artifacts".to_string(),
5937 files: None,
5938 entries_done: None,
5939 entries_total: None,
5940 };
5941
5942 let response = response_value(handle_semantic_search(
5943 &semantic_request("borrowed_loading_needle", 5),
5944 &ctx,
5945 ));
5946 let text = response["text"].as_str().expect("response text");
5947 assert!(text.contains(
5948 "Semantic lane is loading the shared index; lexical results below are complete for exact/identifier matches."
5949 ));
5950 assert!(!text.contains("still building"));
5951 assert!(!text.contains("rebuilding"));
5952 assert!(response["results"]
5953 .as_array()
5954 .is_some_and(|results| !results.is_empty()));
5955 }
5956
5957 #[test]
5958 fn empty_lexical_fallback_names_missing_semantic_coverage() {
5959 let text = format_lexical_unavailable_text(
5960 "Semantic index is loading.",
5961 &[],
5962 Path::new("/fixture"),
5963 "loading",
5964 );
5965
5966 assert!(text.contains("0 lexical matches"));
5967 assert!(text.contains("semantic lane is unavailable"));
5968 assert!(text.contains("prose-style queries may match only via semantic"));
5969 assert!(!text.contains("lexical-only fallback returned 0"));
5970 }
5971
5972 #[test]
5973 fn empty_degraded_grep_fallback_names_missing_semantic_coverage() {
5974 let result = GrepResult {
5975 matches: Vec::new(),
5976 total_matches: 0,
5977 files_searched: 0,
5978 files_with_matches: 0,
5979 index_status: IndexStatus::Fallback,
5980 truncated: false,
5981 fully_degraded: true,
5982 engine_capped: false,
5983 walk_truncated: false,
5984 skipped_foreign_mounts: 0,
5985 };
5986 let text = format_grep_lexical_unavailable_text(
5987 "Semantic index is loading.",
5988 &result,
5989 Path::new("/fixture"),
5990 "loading",
5991 );
5992
5993 assert!(text.contains("0 lexical matches"));
5994 assert!(text.contains("semantic lane is unavailable"));
5995 assert!(!text.contains("lexical-only fallback returned 0"));
5996 }
5997
5998 #[test]
5999 fn first_search_waits_for_slow_borrowed_base_and_returns_results() {
6000 assert_eq!(
6001 FIRST_SEARCH_INDEX_LOAD_WAIT_BUDGET,
6002 Duration::from_millis(2_500)
6003 );
6004 let project = tempfile::tempdir().expect("create project dir");
6005 let source_file = project.path().join("src/lib.rs");
6006 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6007 .expect("create source dir");
6008 let source = "pub fn waited_for_borrowed_artifact() {}\n";
6009 std::fs::write(&source_file, source).expect("write source file");
6010 let ctx = test_context(project.path());
6011 *ctx.semantic_index_status()
6012 .write()
6013 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
6014 stage: "loading_artifacts".to_string(),
6015 files: None,
6016 entries_done: None,
6017 entries_total: None,
6018 };
6019
6020 let (tx, rx) = crossbeam_channel::unbounded();
6021 ctx.install_search_index_rx(rx, ctx.configure_generation());
6022 let publish_file = source_file.clone();
6023 std::thread::spawn(move || {
6024 std::thread::sleep(Duration::from_millis(60));
6025 let mut index = SearchIndex::new();
6026 index.index_file(&publish_file, source.as_bytes());
6027 index.ready = true;
6028 tx.send(index).expect("publish search index");
6029 });
6030
6031 let response =
6037 with_first_search_index_load_wait_budget_for_test(Duration::from_secs(30), || {
6038 response_value(handle_semantic_search(
6039 &semantic_request("waited_for_borrowed_artifact", 5),
6040 &ctx,
6041 ))
6042 });
6043
6044 assert_eq!(response["interpreted_as"], "lexical");
6045 assert!(response["results"]
6046 .as_array()
6047 .is_some_and(|results| !results.is_empty()));
6048 assert!(!response["text"]
6049 .as_str()
6050 .expect("response text")
6051 .contains("nothing was searched yet"));
6052 }
6053
6054 #[test]
6055 fn building_trigram_index_identifier_uses_bounded_walk() {
6056 let project = tempfile::tempdir().expect("create project dir");
6057 let source_file = project.path().join("needle.ts");
6058 std::fs::write(
6059 &source_file,
6060 "export const fresh_root_needle = 'fresh_root_needle';\n",
6061 )
6062 .expect("write source file");
6063
6064 let ctx = test_context(project.path());
6065 *ctx.semantic_index_status()
6066 .write()
6067 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
6068 stage: "loading_artifacts".to_string(),
6069 files: None,
6070 entries_done: None,
6071 entries_total: None,
6072 };
6073 let (_tx, rx) = crossbeam_channel::unbounded::<SearchIndex>();
6074 ctx.install_search_index_rx(rx, ctx.configure_generation());
6075
6076 let raw_response =
6077 with_first_search_index_load_wait_budget_for_test(Duration::from_millis(40), || {
6078 handle_semantic_search(&semantic_request("fresh_root_needle", 5), &ctx)
6079 });
6080 let rendered = crate::subc_format::format_response("search", &raw_response, false);
6081 let response = response_value(raw_response);
6082
6083 assert_eq!(response["success"], true);
6084 assert_eq!(response["status"], "partial");
6085 assert_eq!(response["complete"], false);
6086 assert_eq!(response["semantic_status"], "building");
6087 assert_eq!(response["interpreted_as"], "literal");
6088 let results = response["results"].as_array().expect("results array");
6089 assert!(results.iter().any(|result| {
6090 result["file"]
6091 .as_str()
6092 .is_some_and(|file| file.ends_with("needle.ts"))
6093 }));
6094 let text = response["text"].as_str().expect("response text");
6095 assert!(text.contains(TRIGRAM_BUILDING_BOUNDED_WALK_DISCLOSURE));
6096 assert!(!text.contains("(walk)"));
6099 assert!(!text.contains("(exhausted)"));
6100 assert_eq!(response["results_list_envelope"]["reason"], "walk");
6101 assert_eq!(rendered.matches("(walk)").count(), 1, "{rendered}");
6102 assert!(!rendered.contains("(exhausted)"));
6103 assert_eq!(
6104 response["results_list_envelope"]["total"]["kind"],
6105 "at_least"
6106 );
6107 }
6108
6109 #[test]
6110 fn first_search_wait_budget_expires_with_honest_loading_reply() {
6111 let project = tempfile::tempdir().expect("create project dir");
6112 let ctx = test_context(project.path());
6113 *ctx.semantic_index_status()
6114 .write()
6115 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
6116 stage: "loading_artifacts".to_string(),
6117 files: None,
6118 entries_done: None,
6119 entries_total: None,
6120 };
6121 let (_tx, rx) = crossbeam_channel::unbounded::<SearchIndex>();
6122 ctx.install_search_index_rx(rx, ctx.configure_generation());
6123
6124 let wait_budget = Duration::from_millis(40);
6125 let started = Instant::now();
6126 let raw_response = with_first_search_index_load_wait_budget_for_test(wait_budget, || {
6127 handle_semantic_search(&semantic_request("still_loading", 5), &ctx)
6128 });
6129 let rendered = crate::subc_format::format_response("search", &raw_response, false);
6130 let response = response_value(raw_response);
6131
6132 assert!(started.elapsed() >= wait_budget);
6133 assert!(started.elapsed() < Duration::from_secs(1));
6134 assert_eq!(response["status"], "partial");
6135 let text = response["text"].as_str().expect("response text");
6136 assert!(text.contains(TRIGRAM_BUILDING_BOUNDED_WALK_DISCLOSURE));
6137 assert!(text.contains("Found 0 match"));
6138 assert_eq!(rendered.matches("(walk)").count(), 1, "{rendered}");
6140 assert!(!rendered.contains("(exhausted)"));
6141 }
6142
6143 #[test]
6144 fn first_search_and_in_progress_load_complete_without_deadlock() {
6145 let project = tempfile::tempdir().expect("create project dir");
6146 let source_file = project.path().join("src/lib.rs");
6147 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6148 .expect("create source dir");
6149 let source = "pub fn no_deadlock_borrowed_artifact() {}\n";
6150 std::fs::write(&source_file, source).expect("write source file");
6151 let ctx = Arc::new(test_context(project.path()));
6152 *ctx.semantic_index_status()
6153 .write()
6154 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
6155 stage: "loading_artifacts".to_string(),
6156 files: None,
6157 entries_done: None,
6158 entries_total: None,
6159 };
6160 let (tx, rx) = crossbeam_channel::unbounded();
6161 ctx.install_search_index_rx(rx, ctx.configure_generation());
6162
6163 let publish_file = source_file.clone();
6164 let publisher = std::thread::spawn(move || {
6165 std::thread::sleep(Duration::from_millis(40));
6166 let mut index = SearchIndex::new();
6167 index.index_file(&publish_file, source.as_bytes());
6168 index.ready = true;
6169 tx.send(index).expect("publish search index");
6170 });
6171 let search_ctx = Arc::clone(&ctx);
6172 let (completed_tx, completed_rx) = crossbeam_channel::bounded(1);
6173 let search = std::thread::spawn(move || {
6174 let response = with_first_search_index_load_wait_budget_for_test(
6175 Duration::from_millis(200),
6176 || {
6177 response_value(handle_semantic_search(
6178 &semantic_request("no_deadlock_borrowed_artifact", 5),
6179 &search_ctx,
6180 ))
6181 },
6182 );
6183 completed_tx
6184 .send(response)
6185 .expect("publish search response");
6186 });
6187
6188 let response = completed_rx
6189 .recv_timeout(Duration::from_secs(1))
6190 .expect("search and artifact publication must not deadlock");
6191 assert!(response["results"]
6192 .as_array()
6193 .is_some_and(|results| !results.is_empty()));
6194 publisher.join().expect("publisher joins");
6195 search.join().expect("search joins");
6196 }
6197
6198 #[test]
6199 fn first_search_wait_observes_executor_cancellation() {
6200 let project = tempfile::tempdir().expect("create project dir");
6201 let ctx = Arc::new(test_context(project.path()));
6202 *ctx.semantic_index_status()
6203 .write()
6204 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
6205 stage: "loading_artifacts".to_string(),
6206 files: None,
6207 entries_done: None,
6208 entries_total: None,
6209 };
6210 let (_tx, rx) = crossbeam_channel::unbounded::<SearchIndex>();
6211 ctx.install_search_index_rx(rx, ctx.configure_generation());
6212 let cancellation = crate::executor::JobCancellation::new();
6213 let worker_cancellation = cancellation.clone();
6214 let worker_ctx = Arc::clone(&ctx);
6215 let (started_tx, started_rx) = crossbeam_channel::bounded(1);
6216 let (completed_tx, completed_rx) = crossbeam_channel::bounded(1);
6217 let worker = std::thread::spawn(move || {
6218 let _installed = crate::executor::install_job_cancellation(worker_cancellation);
6219 started_tx.send(()).expect("signal wait start");
6220 let response =
6221 with_first_search_index_load_wait_budget_for_test(Duration::from_secs(2), || {
6222 response_value(handle_semantic_search(
6223 &semantic_request("cancelled_wait", 5),
6224 &worker_ctx,
6225 ))
6226 });
6227 completed_tx
6228 .send(response)
6229 .expect("publish cancelled response");
6230 });
6231
6232 started_rx
6233 .recv_timeout(Duration::from_secs(1))
6234 .expect("search wait starts");
6235 std::thread::sleep(Duration::from_millis(20));
6236 cancellation.request_cancel();
6237 let response = completed_rx
6238 .recv_timeout(Duration::from_secs(1))
6239 .expect("cancelled wait completes promptly");
6240 assert_eq!(response["code"], "request_cancelled");
6241 worker.join().expect("cancelled search joins");
6242 }
6243
6244 #[test]
6245 fn read_only_failed_snapshot_retries_on_semantic_query() {
6246 let project = tempfile::tempdir().expect("create project dir");
6247 let storage = tempfile::tempdir().expect("create storage dir");
6248 let ctx = test_context(project.path());
6249 ctx.update_config(|config| {
6250 config.semantic_search = true;
6251 config.storage_dir = Some(storage.path().to_path_buf());
6252 });
6253 ctx.set_canonical_cache_root(project.path().to_path_buf());
6254 ctx.set_cache_writer_capabilities(false, true);
6255 *ctx.semantic_index_status()
6256 .write()
6257 .unwrap_or_else(std::sync::PoisonError::into_inner) =
6258 SemanticIndexStatus::Failed("shared snapshot absent".to_string());
6259
6260 let response = response_value(handle_semantic_search(
6261 &semantic_request_with_hint("retry snapshot", 5, "semantic"),
6262 &ctx,
6263 ));
6264
6265 assert_eq!(response["success"], true);
6266 assert_eq!(response["status"], "ready");
6267 assert_eq!(response["semantic_status"], "building");
6268 assert!(response["text"]
6269 .as_str()
6270 .expect("semantic fallback text")
6271 .contains("semantic lane is unavailable"));
6272 assert!(ctx.semantic_index_rx().lock().is_some());
6273 ctx.mark_subc_unbound();
6274 ctx.cancel_unbound_artifact_work();
6275 }
6276
6277 #[test]
6278 fn regex_query_runs_without_semantic_index() {
6279 let project = tempfile::tempdir().expect("create project dir");
6280 let source_file = project.path().join("src/lib.rs");
6281 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6282 .expect("create source dir");
6283 std::fs::write(&source_file, "pub fn exported() {}\n").expect("write source file");
6284 let ctx = test_context(project.path());
6285 *ctx.semantic_index_status()
6286 .write()
6287 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
6288
6289 let response = response_value(handle_semantic_search(
6290 &semantic_request_with_hint(".*exported", 5, "regex"),
6291 &ctx,
6292 ));
6293
6294 assert_eq!(response["success"], true);
6295 assert_eq!(response["interpreted_as"], "regex");
6296 assert_eq!(response["query_kind"], "Regex");
6297 assert_eq!(response["semantic_status"], "disabled");
6298 assert_eq!(response["results"][0]["kind"], "GrepLine");
6299 }
6300
6301 #[test]
6302 fn auto_regexlike_uncompilable_query_falls_back_to_literal() {
6303 let project = tempfile::tempdir().expect("create project dir");
6304 let source_file = project.path().join("src/lib.rs");
6305 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6306 .expect("create source dir");
6307 std::fs::write(
6310 &source_file,
6311 "// assert_ne!(.*route_channel\nassert_ne!(route_channel, 0);\n",
6312 )
6313 .expect("write source file");
6314 let ctx = test_context(project.path());
6315
6316 let response = response_value(handle_semantic_search(
6317 &semantic_request_with_hint("assert_ne!(.*route_channel", 5, "auto"),
6318 &ctx,
6319 ));
6320
6321 assert_eq!(response["success"], true);
6322 assert_eq!(response["interpreted_as"], "literal");
6323 let results = response["results"].as_array().expect("results array");
6324 assert!(
6325 !results.is_empty(),
6326 "expected literal fallback result, got {results:?}"
6327 );
6328 assert_eq!(response["results"][0]["source"], "literal");
6329 assert_eq!(
6330 response["results"][0]["match_text"],
6331 "assert_ne!(.*route_channel"
6332 );
6333 let warnings = response["warnings"].as_array().expect("warnings array");
6334 let fallback_warning = warnings
6335 .iter()
6336 .filter_map(|warning| warning.as_str())
6337 .find(|warning| warning.contains("searched literally instead"))
6338 .expect("fallback warning");
6339 assert!(fallback_warning.contains("unclosed group"));
6340 assert!(fallback_warning.contains("Use grep when explicit regex lane control is required."));
6341 }
6342
6343 #[test]
6344 fn legacy_regex_hint_is_ignored_for_uncompilable_query() {
6345 let project = tempfile::tempdir().expect("create project dir");
6346 let ctx = test_context(project.path());
6347
6348 let response = response_value(handle_semantic_search(
6349 &semantic_request_with_hint("assert_ne!(.*route_channel", 5, "regex"),
6350 &ctx,
6351 ));
6352
6353 assert_eq!(response["success"], true);
6354 assert_eq!(response["interpreted_as"], "literal");
6355 assert!(response["text"]
6356 .as_str()
6357 .expect("literal fallback text")
6358 .contains("Found 0"));
6359 }
6360
6361 #[test]
6362 fn valid_auto_regex_query_stays_regex_without_fallback_warning() {
6363 let project = tempfile::tempdir().expect("create project dir");
6364 let source_file = project.path().join("src/lib.rs");
6365 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6366 .expect("create source dir");
6367 std::fs::write(&source_file, "let route_alpha_channel = 1;\n").expect("write source file");
6368 let ctx = test_context(project.path());
6369
6370 let response = response_value(handle_semantic_search(
6371 &semantic_request_with_hint("route_.*channel", 5, "auto"),
6372 &ctx,
6373 ));
6374
6375 assert_eq!(response["success"], true);
6376 assert_eq!(response["interpreted_as"], "regex");
6377 assert_eq!(response["results"][0]["source"], "regex");
6378 let warnings = response["warnings"].as_array().expect("warnings array");
6379 assert!(!warnings.iter().any(|warning| {
6380 warning
6381 .as_str()
6382 .expect("warning")
6383 .contains("searched literally instead")
6384 }));
6385 }
6386
6387 #[test]
6388 fn auto_short_token_warns_and_runs_grep_line_results() {
6389 let project = tempfile::tempdir().expect("create project dir");
6390 let source_file = project.path().join("src/lib.rs");
6391 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6392 .expect("create source dir");
6393 std::fs::write(&source_file, "id = 1\n").expect("write source file");
6394 let ctx = test_context(project.path());
6395
6396 let response = response_value(handle_semantic_search(
6397 &semantic_request_with_hint("id", 5, "literal"),
6398 &ctx,
6399 ));
6400
6401 assert_eq!(response["success"], true);
6402 assert_eq!(response["interpreted_as"], "literal");
6403 assert!(response["warnings"][0]
6404 .as_str()
6405 .expect("warning")
6406 .contains("shorter than 3"));
6407 }
6408
6409 #[test]
6410 fn unsupported_regex_auto_falls_back_to_literal() {
6411 let project = tempfile::tempdir().expect("create project dir");
6412 let ctx = test_context(project.path());
6413
6414 let response = response_value(handle_semantic_search(
6415 &semantic_request_with_hint("(?=foo)", 5, "regex"),
6416 &ctx,
6417 ));
6418
6419 assert_eq!(response["success"], true);
6420 assert_eq!(response["interpreted_as"], "literal");
6421 assert!(response["warnings"]
6422 .as_array()
6423 .expect("warnings")
6424 .iter()
6425 .any(|warning| warning
6426 .as_str()
6427 .is_some_and(|text| text.contains("searched literally instead"))));
6428 }
6429
6430 #[test]
6431 fn regex_zero_results_escalate_once_to_hybrid_terms() {
6432 let project = tempfile::tempdir().expect("create project dir");
6433 let source_file = project.path().join("src/reminder.rs");
6434 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6435 .expect("create source dir");
6436 std::fs::write(
6437 &source_file,
6438 "const missing_route = true;\npub fn exported() {}\n",
6439 )
6440 .expect("write source file");
6441 let ctx = test_context(project.path());
6442 let mut index = SearchIndex::new();
6443 index.index_file(
6444 &source_file,
6445 std::fs::read(&source_file).expect("read source").as_slice(),
6446 );
6447 index.ready = true;
6448 *ctx.search_index()
6449 .write()
6450 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
6451 *ctx.semantic_index_status()
6452 .write()
6453 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
6454
6455 let response = response_value(handle_semantic_search(
6456 &semantic_request("^missing_route$", 5),
6457 &ctx,
6458 ));
6459 assert_eq!(response["success"], true);
6460 assert_eq!(response["result_count"], 1);
6461 assert_eq!(response["zero_result_escalation"], true);
6462 assert!(response["results"]
6463 .as_array()
6464 .expect("results")
6465 .iter()
6466 .any(|result| result["source"] == "lexical"));
6467 assert!(response["text"]
6468 .as_str()
6469 .expect("text")
6470 .contains("[interpreted_as: regex; no exact match — ranked by terms instead]"));
6471
6472 let first_project = tempfile::tempdir().expect("create first-lane project");
6473 let first_source = first_project.path().join("src/lib.rs");
6474 std::fs::create_dir_all(first_source.parent().expect("source parent"))
6475 .expect("create source dir");
6476 std::fs::write(&first_source, "pub fn exported() {}\n").expect("write source file");
6477 let first_ctx = test_context(first_project.path());
6478 *first_ctx
6479 .semantic_index_status()
6480 .write()
6481 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
6482 let first_lane_response = response_value(handle_semantic_search(
6483 &semantic_request_with_hint(".*exported", 5, "regex"),
6484 &first_ctx,
6485 ));
6486 assert!(
6487 first_lane_response["result_count"]
6488 .as_u64()
6489 .is_some_and(|count| count > 0),
6490 "response: {first_lane_response:?}"
6491 );
6492 assert_eq!(
6493 first_lane_response
6494 .get("zero_result_escalation")
6495 .and_then(Value::as_bool),
6496 None,
6497 "response: {first_lane_response:?}"
6498 );
6499 assert!(!first_lane_response["text"]
6500 .as_str()
6501 .expect("text")
6502 .contains("no exact match"));
6503 }
6504
6505 #[test]
6506 fn three_token_quoted_span_routes_as_code_literal_without_embedding() {
6507 let project = tempfile::tempdir().expect("create project dir");
6508 let source_file = project.path().join("src/reminder.rs");
6509 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6510 .expect("create source dir");
6511 std::fs::write(&source_file, "const template = \"outside <touser>\";\n")
6512 .expect("write source file");
6513 let ctx = test_context(project.path());
6514 let mut index = SearchIndex::new();
6515 index.index_file(
6516 &source_file,
6517 std::fs::read(&source_file).expect("read source").as_slice(),
6518 );
6519 index.ready = true;
6520 *ctx.search_index()
6521 .write()
6522 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
6523 *ctx.semantic_index_status()
6524 .write()
6525 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
6526 *ctx.semantic_index()
6527 .write()
6528 .unwrap_or_else(std::sync::PoisonError::into_inner) =
6529 Some(SemanticIndex::new(project.path().to_path_buf(), 3));
6530
6531 let mut request = semantic_request("\"outside <touser>\" reminder text", 5);
6532 request.id = "b2-three-token-quoted-span".to_string();
6533 let response = response_value(handle_semantic_search(&request, &ctx));
6534 assert_eq!(response["success"], true);
6535 assert_eq!(response["interpreted_as"], "engine");
6536 assert_eq!(
6537 response["structuredContent"]["plan"]["shape"],
6538 "code_literal"
6539 );
6540 assert_eq!(
6541 response["structuredContent"]["plan"]["lanes_run"],
6542 serde_json::json!(["exact", "lexical"])
6543 );
6544 assert_eq!(
6545 response["structuredContent"]["search"]["embedding_calls"],
6546 0
6547 );
6548 assert_eq!(
6549 response["structuredContent"]["search"]["embedding_cache_hits"],
6550 0
6551 );
6552 assert_eq!(
6553 response["structuredContent"]["search"]["live_embed_calls"],
6554 0
6555 );
6556 assert!(response.get("zero_result_escalation").is_none());
6557 }
6558
6559 #[test]
6560 fn four_token_quoted_span_remains_natural_language_and_runs_hybrid() {
6561 let project = tempfile::tempdir().expect("create project dir");
6562 let source_file = project.path().join("src/reminder.rs");
6563 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6564 .expect("create source dir");
6565 std::fs::write(&source_file, "const template = \"outside <touser>\";\n")
6566 .expect("write source file");
6567 let ctx = test_context(project.path());
6568 let mut index = SearchIndex::new();
6569 index.index_file(
6570 &source_file,
6571 std::fs::read(&source_file).expect("read source").as_slice(),
6572 );
6573 index.ready = true;
6574 *ctx.search_index()
6575 .write()
6576 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
6577 *ctx.semantic_index_status()
6578 .write()
6579 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
6580 *ctx.semantic_index()
6581 .write()
6582 .unwrap_or_else(std::sync::PoisonError::into_inner) =
6583 Some(SemanticIndex::new(project.path().to_path_buf(), 3));
6584 let (base_url, handle) = start_mock_embedding_server();
6585 ctx.update_config(|config| {
6586 config.semantic.backend = SemanticBackend::OpenAiCompatible;
6587 config.semantic.base_url = Some(base_url);
6588 config.semantic.model = "test-embedding".to_string();
6589 });
6590
6591 let mut request = semantic_request("\"outside <touser>\" reminder text here", 5);
6592 request.id = "b2-four-token-quoted-span".to_string();
6593 let response = response_value(handle_semantic_search(&request, &ctx));
6594 assert_eq!(response["success"], true);
6595 assert_eq!(response["interpreted_as"], "hybrid");
6596 assert_eq!(
6597 response["structuredContent"]["plan"]["shape"],
6598 "natural_language"
6599 );
6600 assert!(response.get("zero_result_escalation").is_none());
6601 handle.join().expect("embedding server thread");
6602 }
6603
6604 #[test]
6605 fn garbage_regex_zero_after_escalation_is_honest_zero() {
6606 let project = tempfile::tempdir().expect("create project dir");
6607 let source_file = project.path().join("src/lib.rs");
6608 std::fs::create_dir_all(source_file.parent().expect("source parent"))
6609 .expect("create source dir");
6610 std::fs::write(&source_file, "const present_route = true;\n").expect("write source file");
6611 let ctx = test_context(project.path());
6612 let mut index = SearchIndex::new();
6613 index.index_file(
6614 &source_file,
6615 std::fs::read(&source_file).expect("read source").as_slice(),
6616 );
6617 index.ready = true;
6618 *ctx.search_index()
6619 .write()
6620 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
6621 *ctx.semantic_index_status()
6622 .write()
6623 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
6624
6625 let response = response_value(handle_semantic_search(
6626 &semantic_request("^qzxjvpl_888$", 5),
6627 &ctx,
6628 ));
6629 assert_eq!(response["success"], true);
6630 assert_eq!(response["result_count"], 0);
6631 assert_eq!(response["zero_result_escalation"], true);
6632 assert!(response["text"]
6633 .as_str()
6634 .expect("text")
6635 .contains("[interpreted_as: regex; no exact match — ranked by terms instead]"));
6636 }
6637
6638 #[test]
6639 fn humanize_degraded_reason_messages() {
6640 let reasons = vec![
6641 "home_root".to_string(),
6642 "watcher_unavailable".to_string(),
6643 "custom".to_string(),
6644 ];
6645 let human = humanize_degraded_reasons(&reasons);
6646 assert!(human[0].contains("home directory"));
6647 assert_eq!(
6648 human[1],
6649 "file watcher unavailable; continuing without live external-change invalidation"
6650 );
6651 assert_eq!(human[2], "(Degraded: custom)");
6652 assert!(human.join("; ").contains("; "));
6653 }
6654
6655 fn rerank_shape(kind: QueryKind) -> QueryShape {
6656 QueryShape {
6657 kind,
6658 weights: query_shape::ShapeWeights {
6659 semantic: 0.0,
6660 lexical: 0.0,
6661 should_use_lexical: false,
6662 },
6663 }
6664 }
6665
6666 fn semantic_candidate(
6667 file: &str,
6668 name: &str,
6669 qualified_name: Option<&str>,
6670 kind: SymbolKind,
6671 score: f32,
6672 ) -> SemanticResult {
6673 SemanticResult {
6674 file: PathBuf::from(file),
6675 name: name.to_string(),
6676 qualified_name: qualified_name.map(str::to_string),
6677 kind,
6678 start_line: 0,
6679 end_line: 0,
6680 exported: false,
6681 snippet: String::new(),
6682 score,
6683 rank_score: score,
6684 cap_protected: false,
6685 source: "semantic",
6686 }
6687 }
6688
6689 fn candidate_rank<'a>(results: &'a [SemanticResult], name: &str) -> &'a SemanticResult {
6690 results
6691 .iter()
6692 .find(|result| result.name == name)
6693 .expect("candidate present")
6694 }
6695
6696 #[test]
6697 fn type_concept_identifier_detector_fires_only_for_titlecase_concepts() {
6698 for query in [
6699 "Engine implementations",
6700 "Engine handlers",
6701 "Allocation strategies",
6702 "EngineFactory implementations",
6703 ] {
6704 let shape = query_shape::classify(query);
6705 assert_eq!(shape.kind, QueryKind::Identifier, "{query}");
6706 assert!(
6707 query_shape::is_type_concept_identifier_query(query, &shape),
6708 "{query} should get definition priors"
6709 );
6710 }
6711
6712 for query in [
6713 "engineFactory",
6714 "useState hook",
6715 "parseConfig option",
6716 "Engine",
6717 ] {
6718 let shape = query_shape::classify(query);
6719 assert_eq!(shape.kind, QueryKind::Identifier, "{query}");
6720 assert!(
6721 !query_shape::is_type_concept_identifier_query(query, &shape),
6722 "{query} should keep Identifier priors inert"
6723 );
6724 }
6725
6726 for query in ["get user", "parse config"] {
6727 let shape = query_shape::classify(query);
6728 assert_eq!(shape.kind, QueryKind::NaturalLanguage, "{query}");
6729 assert!(!query_shape::is_type_concept_identifier_query(
6730 query, &shape
6731 ));
6732 }
6733 }
6734
6735 #[test]
6736 fn type_concept_identifier_diversity_cap_limits_repeated_clusters_only() {
6737 let shape = rerank_shape(QueryKind::Identifier);
6738 let mut repeated_candidates = vec![
6739 semantic_candidate(
6740 "/project/src/a.ts",
6741 "Engine",
6742 Some("Engine"),
6743 SymbolKind::Class,
6744 0.90,
6745 ),
6746 semantic_candidate(
6747 "/project/src/b.ts",
6748 "Engine",
6749 Some("Engine"),
6750 SymbolKind::Class,
6751 0.89,
6752 ),
6753 semantic_candidate(
6754 "/project/src/c.ts",
6755 "Engine",
6756 Some("Engine"),
6757 SymbolKind::Class,
6758 0.88,
6759 ),
6760 ];
6761 rerank_semantic_candidates(&mut repeated_candidates, &shape, "Engine implementations");
6762 assert_eq!(repeated_candidates.len(), 2);
6763 assert!(repeated_candidates
6764 .iter()
6765 .all(|result| result.name == "Engine"));
6766
6767 let mut distinct_candidates = vec![
6768 semantic_candidate(
6769 "/project/src/renderer.ts",
6770 "Renderer",
6771 Some("Renderer"),
6772 SymbolKind::Class,
6773 0.80,
6774 ),
6775 semantic_candidate(
6776 "/project/src/parser.ts",
6777 "Parser",
6778 Some("Parser"),
6779 SymbolKind::Class,
6780 0.79,
6781 ),
6782 semantic_candidate(
6783 "/project/src/planner.ts",
6784 "Planner",
6785 Some("Planner"),
6786 SymbolKind::Class,
6787 0.78,
6788 ),
6789 ];
6790 rerank_semantic_candidates(&mut distinct_candidates, &shape, "Engine implementations");
6791 assert_eq!(distinct_candidates.len(), 3);
6792 assert!(distinct_candidates
6793 .iter()
6794 .all(|result| result.rank_score > result.score));
6795 }
6796
6797 #[test]
6798 fn type_concept_identifier_exact_name_boost_composes_with_kind_prior() {
6799 let shape = rerank_shape(QueryKind::Identifier);
6800 let mut candidates = vec![
6801 semantic_candidate(
6802 "/project/src/engine.ts",
6803 "Engine",
6804 Some("Engine"),
6805 SymbolKind::Class,
6806 0.70,
6807 ),
6808 semantic_candidate(
6809 "/project/src/renderer.ts",
6810 "Renderer",
6811 Some("Renderer"),
6812 SymbolKind::Class,
6813 0.75,
6814 ),
6815 ];
6816
6817 rerank_semantic_candidates(&mut candidates, &shape, "Engine implementations");
6818 let named = candidate_rank(&candidates, "Engine");
6819 let sibling = candidate_rank(&candidates, "Renderer");
6820
6821 assert!(named.rank_score > sibling.rank_score);
6822 assert!((named.rank_score - (0.70 * 1.08 * 1.20)).abs() < 0.0001);
6823 assert!((sibling.rank_score - (0.75 * 1.08)).abs() < 0.0001);
6824 }
6825
6826 #[test]
6827 fn natural_language_diversity_cap_limits_repeated_name_kind_clusters() {
6828 let nl_shape = rerank_shape(QueryKind::NaturalLanguage);
6829 let mixed_shape = rerank_shape(QueryKind::Mixed);
6830 let candidates = vec![
6831 semantic_candidate(
6832 "/project/src/a.ts",
6833 "engineFactory",
6834 None,
6835 SymbolKind::Variable,
6836 0.90,
6837 ),
6838 semantic_candidate(
6839 "/project/src/b.ts",
6840 "engineFactory",
6841 None,
6842 SymbolKind::Variable,
6843 0.89,
6844 ),
6845 semantic_candidate(
6846 "/project/src/c.ts",
6847 "engineFactory",
6848 None,
6849 SymbolKind::Variable,
6850 0.88,
6851 ),
6852 ];
6853
6854 let mut nl_candidates = candidates.clone();
6855 rerank_semantic_candidates(
6856 &mut nl_candidates,
6857 &nl_shape,
6858 "engine factory implementations",
6859 );
6860 assert_eq!(nl_candidates.len(), 2);
6861
6862 let mut mixed_candidates = candidates;
6863 rerank_semantic_candidates(
6864 &mut mixed_candidates,
6865 &mixed_shape,
6866 "engineFactory implementations",
6867 );
6868 assert_eq!(mixed_candidates.len(), 3);
6869 assert!(mixed_candidates
6870 .iter()
6871 .all(|result| (result.rank_score - result.score).abs() < f32::EPSILON));
6872 }
6873
6874 #[test]
6875 fn exact_name_boost_does_not_cap_protect_near_zero_common_names() {
6876 let shape = rerank_shape(QueryKind::Identifier);
6877 let mut candidates = vec![semantic_candidate(
6878 "/project/src/list.ts",
6879 "List",
6880 Some("List"),
6881 SymbolKind::Class,
6882 0.01,
6883 )];
6884
6885 rerank_semantic_candidates(&mut candidates, &shape, "List");
6886
6887 assert!((candidates[0].rank_score - 0.012).abs() < 0.0001);
6888 assert!(!candidates[0].cap_protected);
6889 }
6890
6891 #[test]
6892 fn churned_borrowed_tree_query_completes_with_budget_degradation() {
6893 let session = tempfile::tempdir().expect("session root");
6894 let external = tempfile::tempdir().expect("external root");
6895 let external_root =
6896 std::fs::canonicalize(external.path()).expect("canonical external root");
6897 let git_status = std::process::Command::new("git")
6898 .args(["init", "-q"])
6899 .current_dir(&external_root)
6900 .status()
6901 .expect("initialize external git fixture");
6902 assert!(git_status.success());
6903 let storage = tempfile::tempdir().expect("storage");
6904 for file_index in 0..64 {
6905 let file = external_root.join(format!(
6906 "packages/pkg_{}/src/module_{file_index}.rs",
6907 file_index % 8
6908 ));
6909 std::fs::create_dir_all(file.parent().expect("fixture parent"))
6910 .expect("create nested fixture directory");
6911 std::fs::write(
6912 file,
6913 format!("pub fn borrowed_tree_needle_{file_index}() {{}}\n"),
6914 )
6915 .expect("write borrowed fixture");
6916 }
6917 let cache_dir =
6918 crate::search_index::resolve_cache_dir(&external_root, Some(storage.path()));
6919 let mut index = SearchIndex::build(&external_root);
6920 index.write_to_disk(&cache_dir, None);
6921
6922 let ctx = AppContext::new(
6923 crate::context::default_language_provider_factory(),
6924 crate::config::Config {
6925 project_root: Some(session.path().to_path_buf()),
6926 storage_dir: Some(storage.path().to_path_buf()),
6927 ..crate::config::Config::default()
6928 },
6929 );
6930 let mut req = semantic_request("borrowed_tree_needle", 10);
6931 req.params["path"] = serde_json::json!(external_root);
6932 let first = crate::readonly_artifacts::with_borrowed_search_load_limits_for_test(
6933 10_000,
6934 Duration::from_secs(5),
6935 || response_value(handle_semantic_search(&req, &ctx)),
6936 );
6937 assert_eq!(first["success"], true, "initial borrow failed: {first:?}");
6938
6939 for file_index in 0..40 {
6940 let file = external_root.join(format!(
6941 "packages/pkg_{}/src/module_{file_index}.rs",
6942 file_index % 8
6943 ));
6944 std::fs::write(
6945 file,
6946 format!("pub fn churned_borrowed_tree_{file_index}() {{}}\n"),
6947 )
6948 .expect("churn borrowed fixture");
6949 }
6950 let mut rebuilt = SearchIndex::build(&external_root);
6951 rebuilt.write_to_disk(&cache_dir, None);
6952
6953 let started = Instant::now();
6954 let degraded = crate::readonly_artifacts::with_borrowed_search_load_limits_for_test(
6955 10,
6956 Duration::from_secs(5),
6957 || response_value(handle_semantic_search(&req, &ctx)),
6958 );
6959
6960 assert!(
6961 started.elapsed() < Duration::from_secs(1),
6962 "churned generation must stop at the borrowed-load budget"
6963 );
6964 assert_eq!(degraded["success"], true);
6965 assert_eq!(degraded["complete"], false);
6966 assert_eq!(degraded["fully_degraded"], true);
6967 assert_eq!(
6968 degraded["borrowed_index_degraded_reason"],
6969 "borrowed_search_index_load_budget"
6970 );
6971 assert!(degraded["text"]
6972 .as_str()
6973 .expect("degraded response text")
6974 .ends_with(BORROWED_SEARCH_LOAD_FOOTER));
6975 }
6976
6977 #[test]
6978 fn borrowed_load_budget_degradation_has_locked_disclosure() {
6979 let session = tempfile::tempdir().expect("session root");
6980 let external = tempfile::tempdir().expect("external root");
6981 std::fs::write(
6982 external.path().join("fixture.rs"),
6983 "pub fn budget_disclosure_needle() {}\n",
6984 )
6985 .expect("write external fixture");
6986 let ctx = test_context(session.path());
6987 let req = semantic_request("budget_disclosure_needle", 10);
6988 let params = SemanticSearchParams {
6989 query: "budget_disclosure_needle".to_string(),
6990 top_k: 10,
6991 offset: 0,
6992 include_tests: false,
6993 };
6994 let shape = query_shape::classify(¶ms.query);
6995
6996 let response = response_value(handle_external_borrowed_degraded_fallback(
6997 &req,
6998 &ctx,
6999 ¶ms,
7000 10,
7001 &shape,
7002 external.path(),
7003 crate::readonly_artifacts::BORROWED_SEARCH_LOAD_DEGRADATION,
7004 ));
7005
7006 assert_eq!(response["success"], true);
7007 assert_eq!(response["complete"], false);
7008 assert_eq!(response["fully_degraded"], true);
7009 assert_eq!(
7010 response["borrowed_index_degraded_reason"],
7011 "borrowed_search_index_load_budget"
7012 );
7013 assert!(response["warnings"]
7014 .as_array()
7015 .expect("warnings")
7016 .iter()
7017 .any(|warning| warning.as_str() == Some(BORROWED_SEARCH_LOAD_WARNING)));
7018 assert!(response["text"]
7019 .as_str()
7020 .expect("text")
7021 .ends_with(BORROWED_SEARCH_LOAD_FOOTER));
7022 }
7023
7024 #[test]
7025 fn borrowed_drift_remains_available_in_logs() {
7026 let message = borrowed_drift_log_message("semantic", Path::new("/borrowed"), 3);
7027 assert!(message.contains("borrowed semantic index"));
7028 assert!(message.contains("3 drifted file(s)"));
7029 }
7030
7031 #[test]
7032 fn empty_semantic_index_skips_query_dimension_check() {
7033 let project = tempfile::tempdir().expect("create project dir");
7034 let (base_url, handle) = start_mock_embedding_server();
7035 let ctx = AppContext::new(
7036 Box::new(TreeSitterProvider::new()),
7037 Config {
7038 project_root: Some(project.path().to_path_buf()),
7039 semantic: SemanticBackendConfig {
7040 backend: SemanticBackend::OpenAiCompatible,
7041 model: "test-embedding".to_string(),
7042 base_url: Some(base_url),
7043 api_key_env: None,
7044 timeout_ms: 5_000,
7045 query_timeout_ms: 3_000,
7046 max_batch_size: 64,
7047 max_files: 20_000,
7048 ..Default::default()
7049 },
7050 ..Config::default()
7051 },
7052 );
7053 *ctx.semantic_index_status()
7054 .write()
7055 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
7056 *ctx.semantic_index()
7057 .write()
7058 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7059 Some(SemanticIndex::new(project.path().to_path_buf(), 384));
7060
7061 let response = response_value(handle_semantic_search(
7062 &semantic_request("anything", 5),
7063 &ctx,
7064 ));
7065
7066 assert_eq!(
7067 response["success"], true,
7068 "response should not fail: {response:?}"
7069 );
7070 assert_eq!(response["status"], "ready");
7071 assert_eq!(response["semantic_status"], "ready");
7072 assert!(response["results"].as_array().expect("results").is_empty());
7073 handle.join().expect("embedding server thread");
7074 }
7075
7076 #[test]
7077 fn matching_line_prefers_the_line_with_identifier_variants() {
7078 let dir = tempfile::tempdir().expect("tempdir");
7079 let file = dir.path().join("transform-mode.ts");
7080 std::fs::write(
7081 &file,
7082 "// transform configuration\nexport function resolveTransformMode() {}\nconst unrelated = true;\n",
7083 )
7084 .expect("write matching-line fixture");
7085
7086 let matched = matching_line_from_source(
7087 &file,
7088 "how is transform_mode resolved for every transform pass",
7089 None,
7090 )
7091 .expect("matching line");
7092
7093 assert_eq!(matched.0, 1);
7094 assert_eq!(matched.1, "export function resolveTransformMode() {}");
7095 }
7096
7097 #[test]
7098 fn lexical_matching_line_text_includes_location_and_source_line() {
7099 let project_root = Path::new("/project");
7100 let results = vec![HybridResult {
7101 file: PathBuf::from("/project/src/transform-mode.ts"),
7102 name: "transform-mode".to_string(),
7103 kind: SymbolKind::FileSummary,
7104 start_line: 7,
7105 end_line: 7,
7106 exported: false,
7107 snippet: "export function resolveTransformMode() {".to_string(),
7108 score: 0.75,
7109 source: "lexical",
7110 semantic_score: None,
7111 lexical_score: Some(0.75),
7112 hybrid_boosted: false,
7113 exact: false,
7114 exact_phrase_count: 0,
7115 exact_window_lines: None,
7116 fusion_score: 0.0,
7117 }];
7118
7119 let text = format_semantic_text(&results, project_root, false, false, None);
7120
7121 assert!(text.contains("src/transform-mode.ts:8 [lexical match]"));
7122 assert!(text.contains("export function resolveTransformMode() {"));
7123 }
7124
7125 #[test]
7126 fn file_summary_text_uses_summary_location_instead_of_line_range() {
7127 let project_root = Path::new("/project");
7128 let results = vec![HybridResult {
7129 file: PathBuf::from("/project/src/index.ts"),
7130 name: "index".to_string(),
7131 kind: SymbolKind::FileSummary,
7132 start_line: 0,
7133 end_line: 0,
7134 exported: false,
7135 snippet: String::new(),
7136 score: 0.75,
7137 source: "semantic",
7138 semantic_score: Some(0.75),
7139 lexical_score: None,
7140 hybrid_boosted: false,
7141 exact: false,
7142 exact_phrase_count: 0,
7143 exact_window_lines: None,
7144 fusion_score: 0.0,
7145 }];
7146
7147 let text = format_semantic_text(&results, project_root, false, false, None);
7148
7149 assert!(text.contains("index [file summary]"));
7152 assert!(!text.contains("lines 1-1"));
7153 assert!(!text.contains("score"));
7154 assert!(!text.contains("source semantic"));
7155 }
7156
7157 fn write_symbol_hit(
7161 dir: &Path,
7162 file_name: &str,
7163 name: &str,
7164 body_lines: usize,
7165 ) -> HybridResult {
7166 let path = dir.join(file_name);
7167 if let Some(parent) = path.parent() {
7168 std::fs::create_dir_all(parent).expect("create symbol parent");
7169 }
7170 let body = (0..body_lines)
7171 .map(|i| format!("line{i}"))
7172 .collect::<Vec<_>>()
7173 .join("\n");
7174 std::fs::write(&path, &body).expect("write symbol file");
7175 snippet_hit(
7176 path,
7177 name,
7178 SymbolKind::Function,
7179 0,
7180 body_lines.saturating_sub(1) as u32,
7181 0.5,
7182 )
7183 }
7184
7185 fn snippet_hit(
7186 file: PathBuf,
7187 name: &str,
7188 kind: SymbolKind,
7189 start_line: u32,
7190 end_line: u32,
7191 score: f32,
7192 ) -> HybridResult {
7193 HybridResult {
7194 file,
7195 name: name.to_string(),
7196 kind,
7197 start_line,
7198 end_line,
7199 exported: false,
7200 snippet: String::new(),
7201 score,
7202 source: "semantic",
7203 semantic_score: Some(score),
7204 lexical_score: None,
7205 hybrid_boosted: false,
7206 exact: false,
7207 exact_phrase_count: 0,
7208 exact_window_lines: None,
7209 fusion_score: 0.0,
7210 }
7211 }
7212
7213 #[test]
7214 fn bounded_snippet_enrichment_matches_full_read_reference() {
7215 let dir = tempfile::tempdir().expect("tempdir");
7216 let path = dir.path().join("mid.rs");
7217 std::fs::write(
7218 &path,
7219 "preamble one\npreamble two\n\n/// Explains target.\n#[inline]\nfn target() {\n work();\n}\nmarker after required range\n",
7220 )
7221 .expect("write fixture");
7222 let hit = snippet_hit(
7223 path,
7224 "target",
7225 SymbolKind::Function,
7226 5,
7227 7,
7228 HIGH_CONFIDENCE_COSINE_FLOOR,
7229 );
7230 let mut bounded = vec![hit.clone()];
7231 let mut reference = vec![hit];
7232
7233 let bounded_incomplete = enrich_snippets_from_source(&mut bounded, dir.path());
7234 let reference_incomplete =
7235 enrich_snippets_from_source_reference(&mut reference, dir.path(), None);
7236
7237 assert_eq!(bounded[0].snippet, reference[0].snippet);
7238 assert_eq!(bounded_incomplete, reference_incomplete);
7239 assert!(bounded[0].snippet.contains("Explains target"));
7240 assert!(bounded[0].snippet.contains(RANK0_FULL_SYMBOL_NOTICE));
7241 }
7242
7243 #[test]
7244 fn bounded_snippet_reader_stops_before_later_marker() {
7245 let dir = tempfile::tempdir().expect("tempdir");
7246 let path = dir.path().join("bounded.rs");
7247 std::fs::write(&path, "line0\nline1\nline2\nmarker-must-not-be-read\n")
7248 .expect("write fixture");
7249 let plan = SnippetReadPlan {
7250 fixed_last_line: Some(2),
7251 summary_nonempty_lines: 0,
7252 };
7253
7254 let lines = read_bounded_snippet_lines(&path, plan).expect("bounded read");
7255
7256 assert_eq!(lines, vec!["line0", "line1", "line2"]);
7257 }
7258
7259 #[test]
7260 fn bounded_snippet_reader_ignores_invalid_utf8_only_beyond_required_range() {
7261 let dir = tempfile::tempdir().expect("tempdir");
7262 let path = dir.path().join("invalid-tail.rs");
7263 std::fs::write(&path, b"line0\nline1\n\xff\n").expect("write fixture");
7264
7265 let mut valid_prefix = vec![snippet_hit(
7266 path.clone(),
7267 "prefix",
7268 SymbolKind::Function,
7269 0,
7270 1,
7271 0.5,
7272 )];
7273 let valid_incomplete = enrich_snippets_from_source(&mut valid_prefix, dir.path());
7274 assert_eq!(valid_prefix[0].snippet, "line0\nline1");
7275 assert!(!valid_incomplete);
7276
7277 let mut invalid_range = vec![snippet_hit(
7278 path,
7279 "invalid",
7280 SymbolKind::Function,
7281 0,
7282 2,
7283 0.5,
7284 )];
7285 let invalid_incomplete = enrich_snippets_from_source(&mut invalid_range, dir.path());
7286 assert!(invalid_range[0].snippet.is_empty());
7287 assert!(!invalid_incomplete);
7288 }
7289
7290 #[test]
7291 fn file_summary_and_symbol_share_one_bounded_file_plan() {
7292 let dir = tempfile::tempdir().expect("tempdir");
7293 let path = dir.path().join("shared.rs");
7294 std::fs::write(
7295 &path,
7296 "\nsummary one\n\nfn target() {\n}\nsummary two\nsummary three\nmarker after requirements\n",
7297 )
7298 .expect("write fixture");
7299 let symbol = snippet_hit(path.clone(), "target", SymbolKind::Function, 3, 4, 0.5);
7300 let mut summary = snippet_hit(path, "shared.rs", SymbolKind::FileSummary, 0, 0, 0.5);
7301 summary.snippet = "persisted summary".to_string();
7302 let original = vec![symbol, summary];
7303 let (plans, _) = snippet_read_plans(&original, dir.path(), None);
7304 assert_eq!(plans.len(), 1, "same-file hits must share one read plan");
7305
7306 let mut bounded = original.clone();
7307 let mut reference = original;
7308 let bounded_incomplete = enrich_snippets_from_source(&mut bounded, dir.path());
7309 let reference_incomplete =
7310 enrich_snippets_from_source_reference(&mut reference, dir.path(), None);
7311
7312 assert_eq!(bounded[0].snippet, reference[0].snippet);
7313 assert_eq!(bounded[1].snippet, reference[1].snippet);
7314 assert_eq!(bounded_incomplete, reference_incomplete);
7315 }
7316
7317 #[test]
7318 fn rows_omit_score_and_source() {
7319 let dir = tempfile::tempdir().expect("tempdir");
7320 let mut results = vec![write_symbol_hit(dir.path(), "a.rs", "foo", 2)];
7321 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7322 let text = format_semantic_text(&results, dir.path(), false, incomplete, None);
7323 assert!(text.contains("foo [function] lines 1-2"));
7324 assert!(!text.contains("score"));
7325 assert!(!text.contains("source"));
7326 }
7327
7328 #[test]
7329 fn snippets_are_rank_tiered_top_three_only_from_source() {
7330 let dir = tempfile::tempdir().expect("tempdir");
7331 let mut results: Vec<HybridResult> = (0..5)
7336 .map(|i| write_symbol_hit(dir.path(), &format!("f{i}.rs"), &format!("fn{i}"), 30))
7337 .collect();
7338 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7339 assert!(incomplete);
7340 let text = format_semantic_text(&results, dir.path(), false, incomplete, None);
7341
7342 assert!(text.contains("fn0 [function]"));
7343 assert!(text.contains("+10 more lines"));
7345 assert!(text.contains("+25 more lines"));
7346 let body_lines =
7348 |r: &HybridResult| r.snippet.lines().filter(|l| l.starts_with("line")).count();
7349 assert_eq!(body_lines(&results[0]), 20);
7350 assert_eq!(body_lines(&results[1]), 5);
7351 assert!(
7353 results[3].snippet.is_empty(),
7354 "rank 4+ must have no snippet"
7355 );
7356 assert!(
7357 results[4].snippet.is_empty(),
7358 "rank 4+ must have no snippet"
7359 );
7360 assert!(text.contains("aft_zoom <file> <symbol>"));
7362 }
7363
7364 #[test]
7365 fn high_confidence_rank0_expands_full_symbol_body() {
7366 let dir = tempfile::tempdir().expect("tempdir");
7367 let mut results = vec![write_symbol_hit(dir.path(), "full.rs", "full", 30)];
7368 results[0].semantic_score = Some(HIGH_CONFIDENCE_COSINE_FLOOR);
7369 results[0].score = HIGH_CONFIDENCE_COSINE_FLOOR;
7370
7371 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7372
7373 assert!(
7374 !incomplete,
7375 "full rank-0 symbol should not need a zoom hint"
7376 );
7377 assert!(results[0].snippet.contains("line29"));
7378 assert!(results[0].snippet.contains(RANK0_FULL_SYMBOL_NOTICE));
7379 assert!(!results[0].snippet.contains("+10 more lines"));
7380 }
7381
7382 #[test]
7383 fn subfloor_rank0_keeps_preview_budget() {
7384 let dir = tempfile::tempdir().expect("tempdir");
7385 let mut results = vec![write_symbol_hit(dir.path(), "preview.rs", "preview", 30)];
7386 results[0].semantic_score = Some(HIGH_CONFIDENCE_COSINE_FLOOR - 0.01);
7387 results[0].score = HIGH_CONFIDENCE_COSINE_FLOOR - 0.01;
7388
7389 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7390
7391 assert!(incomplete);
7392 assert!(results[0].snippet.contains("line19"));
7393 assert!(!results[0].snippet.contains("line29"));
7394 assert!(results[0].snippet.contains("+10 more lines"));
7395 }
7396
7397 #[test]
7398 fn test_support_rank0_never_full_expands() {
7399 let dir = tempfile::tempdir().expect("tempdir");
7400 let mut results = vec![write_symbol_hit(
7401 dir.path(),
7402 "fixtures/full.rs",
7403 "fixture",
7404 30,
7405 )];
7406 results[0].semantic_score = Some(0.99);
7407 results[0].score = 0.99;
7408
7409 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7410
7411 assert!(incomplete);
7412 assert!(!results[0].snippet.contains("line29"));
7413 assert!(results[0].snippet.contains("+10 more lines"));
7414 }
7415
7416 #[test]
7417 fn rank0_large_container_renders_member_menu_without_full_notice() {
7418 let dir = tempfile::tempdir().expect("tempdir");
7419 let path = dir.path().join("large.ts");
7420 let mut content = String::from(
7421 "class BigContainer {\n methodOne(): number {\n const visibleMethodBodyLine = 1;\n",
7422 );
7423 for i in 0..155 {
7424 content.push_str(&format!(" const filler{i} = {i};\n"));
7425 }
7426 content.push_str(
7427 " return visibleMethodBodyLine;\n }\n\n methodTwo(): void {\n console.log(\"second\");\n }\n}\n",
7428 );
7429 std::fs::write(&path, content).expect("write large class");
7430 let ctx = test_context(dir.path());
7431 let symbols = ctx.provider().list_symbols(&path).expect("list symbols");
7432 let target = symbols
7433 .iter()
7434 .find(|symbol| symbol.name == "BigContainer")
7435 .expect("BigContainer symbol");
7436 let mut results = vec![HybridResult {
7437 file: path.clone(),
7438 name: "BigContainer".to_string(),
7439 kind: SymbolKind::Class,
7440 start_line: target.range.start_line,
7441 end_line: target.range.end_line,
7442 exported: false,
7443 snippet: String::new(),
7444 score: 0.99,
7445 source: "semantic",
7446 semantic_score: Some(0.99),
7447 lexical_score: None,
7448 hybrid_boosted: false,
7449 exact: false,
7450 exact_phrase_count: 0,
7451 exact_window_lines: None,
7452 fusion_score: 0.0,
7453 }];
7454
7455 let incomplete =
7456 enrich_snippets_from_source_with_context(&mut results, dir.path(), Some(&ctx));
7457 let snippet = &results[0].snippet;
7458
7459 assert!(incomplete, "member menu is not a complete body");
7460 assert!(
7461 snippet.contains("member-signature menu; zoom a member for its body"),
7462 "large container should render a member menu: {snippet}"
7463 );
7464 assert!(
7465 snippet.contains("BigContainer.methodOne(): number"),
7466 "menu should include qualified method signatures: {snippet}"
7467 );
7468 assert!(
7469 !snippet.contains("visibleMethodBodyLine"),
7470 "menu must not include the class body: {snippet}"
7471 );
7472 assert!(
7473 !snippet.contains(RANK0_FULL_SYMBOL_NOTICE),
7474 "member menu must not claim the full symbol was shown: {snippet}"
7475 );
7476
7477 let disabled_ctx = test_context(dir.path());
7478 disabled_ctx.update_config(|config| {
7479 config.disabled_tools.push("aft_zoom".to_string());
7480 });
7481 let mut disabled_results = vec![results[0].clone()];
7482 enrich_snippets_from_source_with_context(
7483 &mut disabled_results,
7484 dir.path(),
7485 Some(&disabled_ctx),
7486 );
7487 assert!(disabled_results[0]
7488 .snippet
7489 .contains("member-signature menu; read a member for its body"));
7490 assert!(!disabled_results[0].snippet.contains("aft_zoom"));
7491 }
7492
7493 #[test]
7494 fn oversized_rank0_full_expansion_renders_budgeted_head_slice() {
7495 let dir = tempfile::tempdir().expect("tempdir");
7498 let mut results = vec![write_symbol_hit(dir.path(), "huge.rs", "huge", 300)];
7499 results[0].semantic_score = Some(0.99);
7500 results[0].score = 0.99;
7501
7502 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7503
7504 assert!(incomplete);
7505 assert!(results[0].snippet.contains("line249"));
7506 assert!(!results[0].snippet.contains("line299"));
7507 assert!(results[0]
7508 .snippet
7509 .contains("… +50 more lines — zoom huge for the full body"));
7510 assert!(
7511 !results[0].snippet.contains(RANK0_FULL_SYMBOL_NOTICE),
7512 "capped fallback is incomplete — must NOT claim no-re-read"
7513 );
7514 }
7515
7516 #[test]
7517 fn rank0_expansion_includes_leading_doc_and_excludes_trailing_neighbor() {
7518 let dir = tempfile::tempdir().expect("tempdir");
7522 let path = dir.path().join("doc.ts");
7523 let content = "import x from 'y';\n\
7524 \n\
7525 /** Does the thing. */\n\
7526 @decorator\n\
7527 export function target() {\n\
7528 \x20\x20return 1;\n\
7529 }\n\
7530 \n\
7531 export function nextSymbol() {\n\
7532 \x20\x20return 2;\n\
7533 }\n";
7534 std::fs::write(&path, content).expect("write");
7535 let mut results = vec![HybridResult {
7538 file: path,
7539 name: "target".to_string(),
7540 kind: SymbolKind::Function,
7541 start_line: 4,
7542 end_line: 6,
7543 exported: true,
7544 snippet: String::new(),
7545 score: 0.99,
7546 source: "semantic",
7547 semantic_score: Some(0.99),
7548 lexical_score: None,
7549 hybrid_boosted: false,
7550 exact: false,
7551 exact_phrase_count: 0,
7552 exact_window_lines: None,
7553 fusion_score: 0.0,
7554 }];
7555
7556 enrich_snippets_from_source(&mut results, dir.path());
7557 let snippet = &results[0].snippet;
7558
7559 assert!(
7560 snippet.contains("Does the thing."),
7561 "leading doc comment must be included: {snippet}"
7562 );
7563 assert!(
7564 snippet.contains("@decorator"),
7565 "leading decorator must be included: {snippet}"
7566 );
7567 assert!(
7568 snippet.contains("export function target()"),
7569 "symbol signature must be present: {snippet}"
7570 );
7571 assert!(
7572 !snippet.contains("nextSymbol"),
7573 "trailing neighbor must NOT bleed in: {snippet}"
7574 );
7575 assert!(
7576 snippet.contains(RANK0_FULL_SYMBOL_NOTICE),
7577 "full expansion must carry the no-re-read notice: {snippet}"
7578 );
7579 }
7580
7581 #[test]
7582 fn rank0_expansion_does_not_include_c_preprocessor_lines() {
7583 let dir = tempfile::tempdir().expect("tempdir");
7584 let path = dir.path().join("target.c");
7585 let content = "#include <x.h>
7586 int target(void) {
7587 return 0;
7588 }
7589";
7590 std::fs::write(&path, content).expect("write");
7591 let mut results = vec![HybridResult {
7592 file: path,
7593 name: "target".to_string(),
7594 kind: SymbolKind::Function,
7595 start_line: 1,
7596 end_line: 3,
7597 exported: false,
7598 snippet: String::new(),
7599 score: HIGH_CONFIDENCE_COSINE_FLOOR,
7600 source: "semantic",
7601 semantic_score: Some(HIGH_CONFIDENCE_COSINE_FLOOR),
7602 lexical_score: None,
7603 hybrid_boosted: false,
7604 exact: false,
7605 exact_phrase_count: 0,
7606 exact_window_lines: None,
7607 fusion_score: 0.0,
7608 }];
7609
7610 enrich_snippets_from_source(&mut results, dir.path());
7611 let snippet = &results[0].snippet;
7612
7613 assert!(
7614 snippet.contains("int target(void)"),
7615 "symbol signature must be present: {snippet}"
7616 );
7617 assert!(
7618 !snippet.contains("#include <x.h>"),
7619 "C preprocessor directives must not be treated as symbol docs: {snippet}"
7620 );
7621 assert!(
7622 snippet.contains(RANK0_FULL_SYMBOL_NOTICE),
7623 "full expansion must carry the no-re-read notice: {snippet}"
7624 );
7625 }
7626
7627 #[test]
7628 fn weak_top_match_emits_low_confidence_note() {
7629 let dir = tempfile::tempdir().expect("tempdir");
7630 let mut hit = write_symbol_hit(dir.path(), "a.rs", "foo", 2);
7631 hit.semantic_score = Some(0.22);
7633 hit.score = 0.22;
7634 let results = vec![hit];
7635 let text = format_semantic_text(&results, dir.path(), false, false, None);
7636 assert!(
7637 text.contains("Top match is weak"),
7638 "expected weak-match note, got: {text}"
7639 );
7640 }
7641
7642 #[test]
7643 fn strong_top_match_has_no_low_confidence_note() {
7644 let dir = tempfile::tempdir().expect("tempdir");
7645 let mut hit = write_symbol_hit(dir.path(), "a.rs", "foo", 2);
7646 hit.semantic_score = Some(0.72);
7647 hit.score = 0.72;
7648 let results = vec![hit];
7649 let text = format_semantic_text(&results, dir.path(), false, false, None);
7650 assert!(!text.contains("Top match is weak"), "got: {text}");
7651 assert!(!text.contains("[index: ready]"), "got: {text}");
7653 }
7654
7655 #[test]
7656 fn no_zoom_hint_when_all_snippets_fit() {
7657 let dir = tempfile::tempdir().expect("tempdir");
7658 let mut results = vec![
7660 write_symbol_hit(dir.path(), "a.rs", "foo", 3),
7661 write_symbol_hit(dir.path(), "b.rs", "bar", 3),
7662 ];
7663 let incomplete = enrich_snippets_from_source(&mut results, dir.path());
7664 assert!(!incomplete);
7665 let text = format_semantic_text(&results, dir.path(), false, incomplete, None);
7666 assert!(!text.contains("+"), "no truncation marker expected: {text}");
7667 assert!(!text.contains("aft_zoom"), "no zoom hint expected: {text}");
7668 }
7669
7670 #[test]
7671 fn enrich_handles_missing_file_gracefully() {
7672 let dir = tempfile::tempdir().expect("tempdir");
7673 let mut results = vec![HybridResult {
7674 file: dir.path().join("does-not-exist.rs"),
7675 name: "ghost".to_string(),
7676 kind: SymbolKind::Function,
7677 start_line: 0,
7678 end_line: 9,
7679 exported: false,
7680 snippet: String::new(),
7681 score: 0.5,
7682 source: "semantic",
7683 semantic_score: Some(0.5),
7684 lexical_score: None,
7685 hybrid_boosted: false,
7686 exact: false,
7687 exact_phrase_count: 0,
7688 exact_window_lines: None,
7689 fusion_score: 0.0,
7690 }];
7691 let _ = enrich_snippets_from_source(&mut results, dir.path());
7693 assert!(results[0].snippet.is_empty());
7694 let text = format_result_sections(&results, dir.path());
7695 assert!(text.contains("ghost [function]"));
7696 }
7697
7698 #[test]
7699 fn groups_render_in_rank_order_not_alphabetical() {
7700 let dir = tempfile::tempdir().expect("tempdir");
7701 let results = vec![
7704 write_symbol_hit(dir.path(), "zzz.rs", "top", 1),
7705 write_symbol_hit(dir.path(), "aaa.rs", "second", 1),
7706 ];
7707 let text = format_result_sections(&results, dir.path());
7708 let zzz_at = text.find("zzz.rs").expect("zzz present");
7709 let aaa_at = text.find("aaa.rs").expect("aaa present");
7710 assert!(zzz_at < aaa_at, "top-ranked file must render first: {text}");
7711 }
7712
7713 #[test]
7714 fn warm_callgraph_adds_compact_blast_radius_suffixes() {
7715 let dir = tempfile::tempdir().expect("tempdir");
7716 let src_dir = dir.path().join("src");
7717 let fixture_dir = dir.path().join("fixtures");
7718 std::fs::create_dir_all(&src_dir).expect("create src");
7719 std::fs::create_dir_all(&fixture_dir).expect("create fixtures");
7720 let target_file = src_dir.join("target.ts");
7721 std::fs::write(
7722 &target_file,
7723 "export function covered() {\n return 1;\n}\nexport function untested() {\n return 2;\n}\n",
7724 )
7725 .expect("write target");
7726 std::fs::write(
7727 src_dir.join("app.ts"),
7728 "import { covered, untested } from './target';\nexport function callerOne() {\n return covered() + untested();\n}\nexport function callerTwo() {\n return covered();\n}\n",
7729 )
7730 .expect("write app");
7731 std::fs::write(
7732 fixture_dir.join("covered_fixture.ts"),
7733 "import { covered } from '../src/target';\nexport function fixtureCaller() {\n return covered();\n}\n",
7734 )
7735 .expect("write fixture");
7736 let ctx = test_context(dir.path());
7737 install_warm_callgraph_store(&ctx, dir.path());
7738
7739 let results = vec![
7740 HybridResult {
7741 file: target_file.clone(),
7742 name: "covered".to_string(),
7743 kind: SymbolKind::Function,
7744 start_line: 0,
7745 end_line: 2,
7746 exported: true,
7747 snippet: String::new(),
7748 score: 0.8,
7749 source: "semantic",
7750 semantic_score: Some(0.8),
7751 lexical_score: None,
7752 hybrid_boosted: false,
7753 exact: false,
7754 exact_phrase_count: 0,
7755 exact_window_lines: None,
7756 fusion_score: 0.0,
7757 },
7758 HybridResult {
7759 file: target_file,
7760 name: "untested".to_string(),
7761 kind: SymbolKind::Function,
7762 start_line: 3,
7763 end_line: 5,
7764 exported: true,
7765 snippet: String::new(),
7766 score: 0.7,
7767 source: "semantic",
7768 semantic_score: Some(0.7),
7769 lexical_score: None,
7770 hybrid_boosted: false,
7771 exact: false,
7772 exact_phrase_count: 0,
7773 exact_window_lines: None,
7774 fusion_score: 0.0,
7775 },
7776 ];
7777
7778 let text = format_semantic_text(&results, dir.path(), false, false, Some(&ctx));
7779 let covered_line = text
7780 .lines()
7781 .find(|line| line.contains("covered [function]"))
7782 .expect("covered row");
7783 assert!(
7784 covered_line.contains("↩"),
7785 "covered row should show callers: {text}"
7786 );
7787 assert!(
7788 covered_line.contains("app.ts") || covered_line.contains("covered_fixture.ts"),
7789 "covered row should include caller basenames: {covered_line}"
7790 );
7791
7792 let untested_line = text
7793 .lines()
7794 .find(|line| line.contains("untested [function]"))
7795 .expect("untested row");
7796 assert!(
7797 untested_line.contains("↩"),
7798 "untested row should show callers: {text}"
7799 );
7800 assert!(
7801 untested_line.contains("app.ts"),
7802 "untested caller basename missing: {untested_line}"
7803 );
7804
7805 assert!(
7810 !text.contains("⚠untested"),
7811 "untested marker must no longer appear in search output: {text}"
7812 );
7813 }
7814
7815 #[test]
7816 fn absent_warm_callgraph_emits_no_blast_radius_and_starts_no_build() {
7817 let dir = tempfile::tempdir().expect("tempdir");
7818 let ctx = test_context(dir.path());
7819 reset_callgraph_cold_build_spawn_count_for_test();
7820 let results = vec![write_symbol_hit(dir.path(), "target.rs", "target", 1)];
7821
7822 let text = format_semantic_text(&results, dir.path(), false, false, Some(&ctx));
7823
7824 assert!(
7825 !text.contains("↩"),
7826 "cold store should not annotate rows: {text}"
7827 );
7828 assert_eq!(callgraph_cold_build_spawn_count_for_test(), 0);
7829 assert!(ctx.callgraph_store_rx().lock().is_none());
7830 }
7831
7832 #[test]
7833 fn more_available_appends_raise_topk_note() {
7834 let dir = tempfile::tempdir().expect("tempdir");
7835 let results = vec![write_symbol_hit(dir.path(), "a.rs", "foo", 1)];
7836 let text = format_semantic_text(&results, dir.path(), true, false, None);
7837 assert!(text.contains("More results available; raise topK to see more."));
7838 }
7839
7840 #[test]
7841 fn file_summary_json_uses_summary_location_instead_of_line_numbers() {
7842 let result = HybridResult {
7843 file: PathBuf::from("/project/src/index.ts"),
7844 name: "index".to_string(),
7845 kind: SymbolKind::FileSummary,
7846 start_line: 0,
7847 end_line: 0,
7848 exported: false,
7849 snippet: String::new(),
7850 score: 0.75,
7851 source: "semantic",
7852 semantic_score: Some(0.75),
7853 lexical_score: None,
7854 hybrid_boosted: false,
7855 exact: false,
7856 exact_phrase_count: 0,
7857 exact_window_lines: None,
7858 fusion_score: 0.0,
7859 };
7860
7861 let json = result_to_json(&result);
7862
7863 assert_eq!(json["kind"], "file_summary");
7864 assert_eq!(json["location"], "[file summary]");
7865 assert!(json["start_line"].is_null());
7866 assert!(json["end_line"].is_null());
7867 assert_eq!(json["source"], "semantic");
7868 assert_eq!(json["semantic_score"], 0.75);
7869 assert!(json["lexical_score"].is_null());
7870 }
7871
7872 #[test]
7873 fn engine_reply_renders_one_shared_envelope_trailer_with_offset() {
7874 let project = tempfile::tempdir().expect("create project dir");
7875 let mut index = SearchIndex::new();
7876 for file_number in 0..6 {
7877 let source_file = project.path().join(format!("src/file_{file_number}.rs"));
7878 std::fs::create_dir_all(source_file.parent().expect("source parent"))
7879 .expect("create source dir");
7880 let source = format!("pub fn shared_trailer_marker_{file_number}() {{}}\n");
7881 std::fs::write(&source_file, &source).expect("write source");
7882 index.index_file(&source_file, source.as_bytes());
7883 }
7884 index.ready = true;
7885
7886 let ctx = test_context(project.path());
7887 *ctx.search_index()
7888 .write()
7889 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
7890 *ctx.semantic_index_status()
7891 .write()
7892 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
7893
7894 let response = handle_semantic_search(
7895 &semantic_request_with_hint("shared_trailer_marker", 3, "literal"),
7896 &ctx,
7897 );
7898 assert!(response.success, "engine search failed: {response:?}");
7899 assert!(response.data.get("results_list_envelope").is_some());
7900 let rendered = crate::subc_format::format_response("search", &response, false);
7901 let trailer_lines = rendered
7902 .lines()
7903 .filter(|line| line.starts_with("shown "))
7904 .collect::<Vec<_>>();
7905 assert_eq!(
7906 trailer_lines.len(),
7907 1,
7908 "engine reply must render exactly one trailer: {rendered}"
7909 );
7910 assert!(trailer_lines[0].ends_with(" · narrow: offset, topK, path, includeTests"));
7911 assert!(!rendered.contains("More results available; raise topK to see more."));
7912 }
7913
7914 #[test]
7915 fn ready_uncontended_search_keeps_indexed_literal_results() {
7916 let project = tempfile::tempdir().expect("create project dir");
7917 let source_file = project.path().join("src/lib.rs");
7918 std::fs::create_dir_all(source_file.parent().expect("source parent"))
7919 .expect("create source dir");
7920 std::fs::write(&source_file, "pub fn parity_marker() {}\n").expect("write source");
7921 let ctx = test_context(project.path());
7922 let mut index = SearchIndex::new();
7923 index.index_file(&source_file, b"pub fn parity_marker() {}\n");
7924 index.ready = true;
7925 *ctx.search_index()
7926 .write()
7927 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
7928 *ctx.semantic_index_status()
7929 .write()
7930 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
7931
7932 let response = response_value(handle_semantic_search(
7933 &semantic_request_with_hint("parity_marker", 5, "literal"),
7934 &ctx,
7935 ));
7936 assert_eq!(response["success"], true);
7937 assert_eq!(response["interpreted_as"], "lexical");
7938 assert_eq!(
7939 response["results"][0]["source"], "exact",
7940 "the live exact lane now owns verbatim identifier matches"
7941 );
7942 assert!(response["results"][0]["file"]
7943 .as_str()
7944 .expect("result file")
7945 .ends_with("src/lib.rs"));
7946 }
7947
7948 #[test]
7949 fn repeated_paged_search_reads_exact_candidates_once_per_query_generation() {
7950 let project = tempfile::tempdir().expect("create project dir");
7951 let source_file = project.path().join("src/lib.rs");
7952 std::fs::create_dir_all(source_file.parent().expect("source parent"))
7953 .expect("create source dir");
7954 std::fs::write(&source_file, "pub fn repeated_page_marker() {}\n").expect("write source");
7955 let ctx = test_context(project.path());
7956 let mut index = SearchIndex::new();
7957 index.index_file(&source_file, b"pub fn repeated_page_marker() {}\n");
7958 index.ready = true;
7959 *ctx.search_index()
7960 .write()
7961 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
7962 *ctx.semantic_index_status()
7963 .write()
7964 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
7965
7966 let pages = [(100, 0), (100, 100), (100, 200), (100, 300)]
7967 .into_iter()
7968 .chain((0..10).map(|page| (10, page * 10)))
7969 .chain((0..4).map(|page| (25, page * 25)))
7970 .chain([(100, 0)]);
7971 for (top_k, offset) in pages {
7972 let response = response_value(handle_semantic_search(
7973 &semantic_page_request("repeated_page_marker", top_k, offset),
7974 &ctx,
7975 ));
7976 assert_eq!(response["success"], true);
7977 }
7978
7979 assert_eq!(ctx.search_exact_memo().verifier_call_count(), 1);
7980 }
7981
7982 #[test]
7983 fn exact_page_memo_keys_include_corpus_generation() {
7984 let project = tempfile::tempdir().expect("create project dir");
7985 let first = project.path().join("src/a.rs");
7986 let second = project.path().join("src/b.rs");
7987 std::fs::create_dir_all(first.parent().expect("source parent")).expect("create source dir");
7988 std::fs::write(&first, "pub fn generation_marker() {}\n").expect("write first source");
7989 std::fs::write(&second, "pub fn generation_marker() {}\n").expect("write second source");
7990 let ctx = test_context(project.path());
7991 let mut index = SearchIndex::new();
7992 index.index_file(&first, b"pub fn generation_marker() {}\n");
7993 index.index_file(&second, b"pub fn generation_marker() {}\n");
7994 index.ready = true;
7995 *ctx.search_index()
7996 .write()
7997 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
7998 *ctx.semantic_index_status()
7999 .write()
8000 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Disabled;
8001
8002 let before = response_value(handle_semantic_search(
8003 &semantic_page_request("generation_marker", 1, 0),
8004 &ctx,
8005 ));
8006 assert!(before["results"][0]["file"]
8007 .as_str()
8008 .expect("first result path")
8009 .ends_with("src/a.rs"));
8010
8011 std::fs::write(&first, "pub fn unrelated() {}\n").expect("edit first source");
8012 ctx.search_index()
8013 .write()
8014 .unwrap_or_else(std::sync::PoisonError::into_inner)
8015 .as_mut()
8016 .expect("installed search index")
8017 .update_file(&first);
8018 ctx.note_search_index_rx_generation(1);
8019
8020 let after = response_value(handle_semantic_search(
8021 &semantic_page_request("generation_marker", 1, 0),
8022 &ctx,
8023 ));
8024 assert!(after["results"][0]["file"]
8025 .as_str()
8026 .expect("updated result path")
8027 .ends_with("src/b.rs"));
8028 assert_eq!(ctx.search_exact_memo().verifier_call_count(), 2);
8029 }
8030
8031 #[test]
8032 fn contended_search_index_degrades_with_disclosure() {
8033 let project = tempfile::tempdir().expect("create project dir");
8034 let source_file = project.path().join("src/lib.rs");
8035 std::fs::create_dir_all(source_file.parent().expect("source parent"))
8036 .expect("create source dir");
8037 std::fs::write(&source_file, "pub fn contention_marker() {}\n").expect("write source");
8038 let ctx = test_context(project.path());
8039 let writer_guard = ctx
8040 .search_index()
8041 .write()
8042 .unwrap_or_else(std::sync::PoisonError::into_inner);
8043 let started = std::time::Instant::now();
8044 let response = response_value(handle_semantic_search(
8045 &semantic_request("contention_marker", 5),
8046 &ctx,
8047 ));
8048 drop(writer_guard);
8049
8050 assert!(
8051 started.elapsed() < Duration::from_secs(2),
8052 "contended search exceeded bounded response time"
8053 );
8054 assert_eq!(response["success"], true);
8055 assert_eq!(response["lexical_only_fallback"], true);
8056 assert!(response["text"]
8057 .as_str()
8058 .expect("fallback text")
8059 .contains("artifact contention"));
8060 }
8061
8062 #[test]
8063 fn view_semantic_reader_scores_vectors_from_manifest_blobs() {
8064 let project = tempfile::tempdir().expect("project");
8065 let storage = tempfile::tempdir().expect("storage");
8066 std::fs::write(project.path().join("lib.rs"), "pub fn needle() {}\n").unwrap();
8067 let mut store = crate::blob_store::BlobStore::open(
8068 storage.path(),
8069 "semantic-reader-family",
8070 crate::blob_store::BlobPlane::Semantic,
8071 )
8072 .unwrap();
8073 let key = crate::blob_store::SemanticKey::for_current(
8074 b"pub fn needle() {}\n",
8075 b"lib.rs",
8076 "fingerprint",
8077 )
8078 .full_key();
8079 let mut payload = vec![1];
8080 let push = |payload: &mut Vec<u8>, bytes: &[u8]| {
8081 payload.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
8082 payload.extend_from_slice(bytes);
8083 };
8084 push(&mut payload, b"semantic-v1");
8085 push(&mut payload, b"semantic-v1");
8086 push(&mut payload, b"fingerprint");
8087 payload.extend_from_slice(&1_u32.to_le_bytes());
8088 push(&mut payload, b"needle");
8089 push(&mut payload, b"");
8090 payload.push(0);
8091 payload.extend_from_slice(&0_u32.to_le_bytes());
8092 payload.extend_from_slice(&0_u32.to_le_bytes());
8093 payload.push(1);
8094 push(&mut payload, b"pub fn needle() {}");
8095 push(&mut payload, b"needle function");
8096 let vector = [1.0_f32, 0.0_f32]
8097 .into_iter()
8098 .flat_map(f32::to_le_bytes)
8099 .collect::<Vec<_>>();
8100 push(&mut payload, &vector);
8101 store.put(&key, &payload).unwrap();
8102 let manifest = crate::views::Manifest::new([(
8103 crate::views::RelPath::new(b"lib.rs".to_vec()).unwrap(),
8104 crate::views::ManifestEntry::Regular {
8105 mode: 0o100644,
8106 planes: crate::views::RegularPlanes {
8107 semantic: Some(key.to_hex()),
8108 callgraph: None,
8109 },
8110 resolution_input: false,
8111 },
8112 )])
8113 .unwrap();
8114 let view = crate::context::ViewRuntimeSnapshot {
8115 query_pin: None,
8116 storage: storage.path().to_path_buf(),
8117 family: "semantic-reader-family".to_string(),
8118 scope: "semantic-reader-view".to_string(),
8119 view_dir: storage.path().join("views/semantic-reader-view"),
8120 generation: Some("1-head".to_string()),
8121 manifest: Some(manifest),
8122 head_fingerprint: "head".to_owned(),
8123 head_metadata: crate::alias::GitHeadMetadata {
8124 head_path: project.path().join(".git/HEAD"),
8125 head_mtime: None,
8126 resolved_ref_path: None,
8127 resolved_ref_mtime: None,
8128 },
8129 pending_paths: Default::default(),
8130 };
8131
8132 let results = view_semantic_search(&view, project.path(), &[1.0, 0.0], 5, true).unwrap();
8133 assert_eq!(results.len(), 1);
8134 assert_eq!(results[0].name, "needle");
8135 assert_eq!(results[0].score, 1.0);
8136 }
8137
8138 #[test]
8139 fn contended_callgraph_receiver_does_not_block_search_formatting() {
8140 let project = tempfile::tempdir().expect("create project dir");
8141 let ctx = test_context(project.path());
8142 let receiver_guard = ctx.callgraph_store_rx().lock();
8143 let started = std::time::Instant::now();
8144 assert!(warm_callgraph_store(&ctx).is_none());
8145 assert!(
8146 started.elapsed() < INTERACTIVE_ARTIFACT_READ_BUDGET,
8147 "callgraph receiver contention exceeded artifact budget"
8148 );
8149 drop(receiver_guard);
8150 }
8151}