Skip to main content

aft/commands/semantic_search/
mod.rs

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
29/// Immutable request input passed to a registered lane callback.
30pub 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
44/// Lane-registration seam: every registration carries its execution callback.
45pub 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/// Lane-registration seam: registry holding participating search lanes.
59#[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;
130/// Semantic candidate admission is ranking policy, not a public response-size limit.
131/// Keeping it fixed prevents page-cap changes from rewriting the fused list.
132pub 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);
136/// Fresh borrowed trigram bases were measured ready 1.1–2.5 seconds after root
137/// bind. Waiting through that observed window prevents a healthy first search
138/// from returning an empty loading response while keeping a stuck load bounded.
139const 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.";
180/// Cap on the rank-0 full-symbol preview. Sized to absorb the follow-up zoom for
181/// virtually every real function/type so the agent doesn't re-read a file it
182/// already saw in search; a symbol exceeding it falls back to the line-budget
183/// preview + "+N more lines". aft_zoom itself is uncapped, but search expansion is
184/// automatic (not explicitly requested), so a runaway giant stays bounded.
185const RANK0_FULL_SNIPPET_MAX_LINES: usize = 250;
186
187/// Appended under the rank-0 snippet ONLY when the complete symbol was shown
188/// (full expansion, not the capped preview). Tells the agent the body is the live
189/// on-disk content so it can edit directly instead of spending a redundant
190/// zoom/read — which is the entire point of the full-symbol expansion. It must
191/// never appear on a partial preview (ranks 1-2, or the >cap fallback that ends
192/// in "+N more lines"), where re-reading IS needed.
193const 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        // A writer-held pointer cannot be advanced by the loader drain below;
561        // preserve the interactive contention bound instead of sleeping 2.5s.
562        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    // The extensions come from the search_b2 install point: A-side defaults
718    // until campaign B2 installs its router, plans, variants and readiness.
719    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    // Quoting is part of QueryFacts and remains visible to the plan hook. Only
844    // the code-literal execution route removes its balanced delimiter pair.
845    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(&params.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                &params,
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                &params,
898                &shape,
899                &project_root,
900                top_k,
901                "search index remained busy",
902            );
903        }
904    };
905    let mode = choose_mode(&params.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            &params,
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            &params.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                    &params,
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                    &params,
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        &params.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            &params.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
1085/// Bounded lexical scan of a foreign git root that has no borrowable AFT
1086/// index. Mirrors the `grep` tool's unindexed-external behavior (fallback
1087/// filesystem walk via the shared `grep_executor`) so `aft_search` degrades to
1088/// real results with a disclosure instead of a hard `not_indexed` error.
1089/// Semantic/hybrid intent cannot run without embeddings, so every mode degrades
1090/// to a lexical substring/regex scan here.
1091fn 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        &params.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: &params.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(&params.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, &params.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        &params.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: &params.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
1669/// Whether `path` is something `aft_search` hides unless `include_tests` is set:
1670/// a test-support file (fixtures/mocks/snapshots) OR an actual test file
1671/// (`*.test.ts`, `__tests__/`, `*_test.rs`, …). Search is a code-discovery tool,
1672/// so test code is noise by default; `include_tests: true` shows both classes.
1673fn 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, &params);
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        // The trailer is not rendered here: the shared formatters append it
1926        // from the wire envelope below, and the contract keeps every trailer
1927        // on that one path so no tool can print it twice or word it differently.
1928        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    // The ranked unit is the file: after the comparator has put the most
2471    // specific evidence first (a bounded declaration span above a file-level
2472    // phrase hit), only the leading candidate per path survives. Keying on the
2473    // span as well let one file occupy two rows once declaration evidence
2474    // arrived beside the file-level hit, which broke the one-file-per-page
2475    // contract and rendered the same file twice.
2476    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        &params.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: &params.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                &params,
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                &params,
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                    &params.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                &params,
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                &params,
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            &params,
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(&params.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                &params,
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    // Candidate enumeration is fixed across page sizes so every requested
3206    // interval is cut from the same ranked tuple.
3207    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                    &params,
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        &params.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            &params.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    // No score threshold: silent filtering produced "0 results" even when the
3313    // model had reasonable matches the agent could have judged. Surface every
3314    // hit so the caller can decide.
3315
3316    // Read display snippets from source on the fly (top 3 only, rank-budgeted)
3317    // so both the text rendering and the JSON `results` carry fresh, correctly
3318    // sized previews. Drives the conditional zoom hint.
3319    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: &params.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
3400/// Render the single lexical second chance used after an auto-routed lane
3401/// returns no results. The escalation uses the same exact and lexical engine
3402/// lanes as an ordinary natural-language request.
3403fn 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            &params.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: &params.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        &params.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: &params.query,
3777            // This path ran a literal grep scan over the corpus (the results are
3778            // GrepLine entries), so report "literal" — not the routed
3779            // semantic/hybrid mode that never executed.
3780            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    // Rayon collection order is not part of the response contract; restore the
3929    // original walker order before applying the existing result-cap semantics.
3930    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    // Keep degraded semantic search on the root filesystem: ReadDir::drop can
3988    // abort the daemon if a disappearing child mount reports ENXIO.
3989    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        // The loader publishes through a channel; the query holds neither the
4223        // index nor receiver lock while draining, so it cannot block publication.
4224        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    // Preserve the raw error so the timeout marker injected by
4300    // `send_embedding_request` survives — `classify_embed_query_error` reads it
4301    // to decide whether the configured query budget fired.
4302    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
4320/// Classified query-embedding failure: the user-facing `detail` line that names
4321/// the mechanism and the remedy when the configured query budget fired, and a
4322/// short `footer_reason` token for the `[semantic: ...]` status footer the agent
4323/// sees. Non-timeout errors keep the current message shape and an `unavailable`
4324/// footer.
4325///
4326/// The timeout case is detected via the stable marker
4327/// [`crate::semantic_index::query_embedding_timeout_budget`] injects at the one
4328/// site that knows both the typed reqwest error and the Query budget — never by
4329/// substring-matching reqwest's rendered text, which varies by backend/locale.
4330struct 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        // Name the mechanism (the budget fired) and the remedy (raise the knob
4339        // for slow providers) so the agent can act, not just observe.
4340        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
4550/// Top semantic cosine below this floor means the embedder found nothing
4551/// genuinely relevant — the query likely whiffed. We don't show the raw score
4552/// (uncalibrated for ranking), but its absolute floor is a real signal: an
4553/// all-weak result set looks identical to a strong one without it.
4554const 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;
4558// Intentionally high: the default MiniLM scores are uncalibrated, so under-trigger rather than over-promise.
4559const HIGH_CONFIDENCE_COSINE_FLOOR: f32 = 0.60;
4560
4561/// True when the best result's raw semantic cosine is below the weak floor.
4562/// Uses `semantic_score` (the raw cosine), not the fused `score`. Lexical-only
4563/// top results have no cosine and are not flagged here (lexical relevance is
4564/// judged differently).
4565fn 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    // Drop the unconditional "[index: ready]" tag — it was pure per-call tax on
4601    // the common path. Degraded/building/unavailable paths carry their own
4602    // distinct "[semantic: ...]" labels, so absence of a label means ready.
4603    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    // Recover the "did the search whiff" signal we lost by hiding the score:
4608    // one coarse flag when the top match is weak, so the agent reformulates or
4609    // falls back to grep instead of trusting a uniformly-weak ranking.
4610    if results_are_low_confidence(results) {
4611        text.push_str("\nTop match is weak — consider rephrasing or using grep for exact terms.");
4612    }
4613    // Only when snippet content was actually withheld (omitted for rank 4+, or
4614    // truncated within the top 3) — so the hint appears exactly when it's
4615    // actionable, not on every search.
4616    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
4635/// Snippet line budget by global rank (0-based). The fused score is an
4636/// uncalibrated, scale-mixed artifact (raw cosine for semantic-only hits,
4637/// cosine×boost for lexically-co-matched hits), so it is NOT shown to the
4638/// agent — position conveys rank. We spend snippet tokens by rank instead: the
4639/// top hit is disproportionately likely to be the final answer (a fuller
4640/// preview there can save a follow-up aft_zoom), tail hits only need to be
4641/// identifiable. Snippets are limited to the top 3; rank 4+ shows the symbol
4642/// header only and the agent zooms the ones it cares about.
4643fn snippet_line_budget(global_rank: usize) -> usize {
4644    match global_rank {
4645        // Rank 0 gets a fuller preview: 10 lines was often half a real function,
4646        // forcing a zoom anyway and defeating the "preview saves a follow-up"
4647        // goal. 20 (capped at the symbol's real length) clears most functions.
4648        0 => 20,
4649        1 | 2 => 5,
4650        _ => 0,
4651    }
4652}
4653
4654/// Replace each result's display snippet with source lines read on the fly from
4655/// disk, bounded by the rank budget. Snippets are display-only (they never
4656/// affect embeddings), so reading them at query time keeps the on-disk index
4657/// free of display text, lets snippet sizing change without a re-index, and
4658/// shows the current file content instead of whatever was captured at index
4659/// time. Only the top 3 carry snippets; rank 4+ get a header only and the agent
4660/// zooms the ones it cares about. Lexical rows keep their placeholder and file
4661/// summaries keep the generated summary (not source lines). Returns true when
4662/// any snippet was truncated or omitted, so the caller emits the zoom hint only
4663/// when it is actionable.
4664#[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    // Read each source once and stop after every requested symbol range and
4737    // FileSummary budget is satisfied. Any error before that boundary discards
4738    // the whole partial read, matching the previous read_to_string behavior.
4739    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            // Header-only tier: a real body means there is more to see.
4777            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            // File unreadable or gone — no snippet beats a stale one.
4806            result.snippet = String::new();
4807            continue;
4808        };
4809
4810        // start_line/end_line are 0-based inclusive; +1 makes an exclusive bound.
4811        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                    // Append the full-body notice only for Complete so callers know
4824                    // they received the entire symbol source. Skip it for Truncated
4825                    // or Menu results.
4826                    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            // "lines" is load-bearing: a bare "+N more" reads as "N more
4843            // results" to a weak model, prompting a wrong topK bump. This is
4844            // N more lines of THIS symbol's body — zoom to see them.
4845            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    // Results arrive sorted by fused score desc. Group by file preserving
5037    // first-appearance order so the most relevant file's group renders first.
5038    // A BTreeMap would re-sort groups alphabetically by path and scramble the
5039    // ranking the agent relies on to read most-relevant-first. Snippets are
5040    // already budgeted by enrich_snippets_from_source; render them verbatim.
5041    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            // Three distinct indent levels disambiguate the three roles for a
5089            // weak model at a glance: file path at col 0 (with its `/` and
5090            // extension), symbol header at 2 spaces, snippet body at 6. Without
5091            // this, file paths and symbol headers were both at col 0 and could
5092            // only be told apart by parsing the "[kind] lines X-Y" suffix.
5093            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                    // A lexical result without a readable source line keeps the file-level marker.
5099                    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
5291/// Honest `interpreted_as` for a response built on a semantic-unavailable
5292/// fallback path. The query may have been *routed* as semantic/hybrid, but if
5293/// semantic never executed, the field must report what actually produced the
5294/// results — otherwise an agent reads "hybrid" and trusts a semantic ranking
5295/// that never ran. `lexical_ran` is true when the lexical (trigram) lane
5296/// produced the returned results; otherwise we report the routed mode (the
5297/// attempt), with the `semantic_unavailable`/`status` fields conveying that it
5298/// could not run.
5299fn 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
5318/// Strip one matched surrounding delimiter from a literal query. Quotes and
5319/// backticks are recognized because all three can select the code-literal
5320/// route; mismatched or already stripped input is left unchanged.
5321fn 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        // A query-embedding timeout carries the budget that fired. The
5697        // classified detail must name the mechanism, the budget value, and the
5698        // knob that raises it — and the footer reason must carry the budget so
5699        // the agent sees the cause in the status line, not just the body.
5700        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        // A non-timeout failure (HTTP 4xx, connection refused, dimension
5726        // mismatch) must NOT claim a timeout. It keeps the current message
5727        // shape and the plain "unavailable" footer reason.
5728        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        // "parse imports" classifies as a two-word lowercase NL concept, but it
5773        // is a literal code phrase the trigram lane can hit. With lexical ready
5774        // it must route to Hybrid (run the lexical lane), not pure Semantic.
5775        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        // The short-NL Hybrid path needs tokens; extract_tokens returns none for
5809        // NL, so collect_lexical_files uses the short-NL extractor.
5810        let tokens = query_shape::extract_short_nl_lexical_tokens("parse imports");
5811        assert_eq!(tokens, vec!["parse".to_string(), "imports".to_string()]);
5812        // Sub-3-char words are dropped (trigram floor).
5813        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        // While semantic builds, only index-backed lanes produce results. The
5892        // existing "lexical" label means no embedding lane ran; exact evidence
5893        // may still refine those index-backed results.
5894        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        // Decision-evidence form: non-empty index-backed results prove the query
6032        // waited for publication rather than taking the partial bounded walk.
6033        // Elapsed-time bounds were removed because a loaded runner can deschedule
6034        // either the publisher thread or this thread past any tight wall-clock budget;
6035        // the generous budget below is a hang catch, not a timing assertion.
6036        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        // The handler never renders the trailer itself; the shared formatter
6097        // appends it from the wire envelope exactly once.
6098        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        // The trailer is the shared formatter's, appended from the wire envelope.
6139        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        // The fallback recompiles as an escaped literal, so the exact needle
6308        // must be present for this test to produce a match.
6309        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(&params.query);
6995
6996        let response = response_value(handle_external_borrowed_degraded_fallback(
6997            &req,
6998            &ctx,
6999            &params,
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        // File-summary rows show "[file summary]" with no line range, and no
7150        // longer leak the internal score/source.
7151        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    /// A symbol hit whose `file` points at a real on-disk file with `body_lines`
7158    /// lines starting at line 0, so enrich_snippets_from_source can read it. The
7159    /// stored `snippet` is left empty on purpose — enrichment fills it from disk.
7160    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        // Five hits, each a 30-line body, in distinct files so grouping does not
7332        // merge them. Rank order = vector order (already sorted). Budgets:
7333        // rank 0 = 20 lines (+10 more lines), ranks 1-2 = 5 lines (+25 more
7334        // lines), rank 3+ = header only.
7335        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        // "lines" wording is load-bearing (vs "+N more" reading as results).
7344        assert!(text.contains("+10 more lines"));
7345        assert!(text.contains("+25 more lines"));
7346        // Rank 0 genuinely shows MORE than ranks 1-2 (gradient not inverted).
7347        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        // Ranks 3,4 → header only, no body lines.
7352        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        // Zoom hint present because snippets were withheld.
7361        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        // Use a 300-line symbol so the top result is truncated to a head slice,
7496        // setting incomplete=true instead of using the small default preview.
7497        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        // A symbol preceded by a doc comment and a decorator, with a NEXT symbol
7519        // immediately after. Rank-0 expansion must show the doc + the symbol, and
7520        // must NOT bleed the following symbol in.
7521        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        // target() body spans the `export function target` line (index 4) through
7536        // its closing brace (index 6), 0-based inclusive.
7537        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        // Top semantic cosine below the weak floor.
7632        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        // And no unconditional "[index: ready]" tax on the happy path.
7652        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        // Two small symbols (3 lines each), both within their rank budget.
7659        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        // Must not panic; header renders, no snippet body.
7692        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        // zzz.rs holds the top hit, aaa.rs the second. Alphabetical grouping
7702        // (the old BTreeMap bug) would put aaa.rs first; rank order keeps zzz.
7703        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        // The `⚠untested` marker was removed: it provided no actionable signal in
7806        // a discovery tool and relied on is_test_support_file (fixtures/mocks
7807        // only), so it false-flagged genuinely tested code. Blast radius keeps
7808        // only the accurate `↩callers` + basenames.
7809        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}