Skip to main content

aft/
semantic_index.rs

1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
2use crate::config::{
3    SemanticBackend, SemanticBackendConfig, DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
4    MAX_SEMANTIC_QUERY_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
5};
6use crate::fs_lock;
7use crate::parser::{detect_language, extract_symbols_from_tree, parse_source_with_cached_parser};
8use crate::search_index::{cache_relative_path, cached_path_under_root};
9use crate::symbols::{Symbol, SymbolKind};
10use crate::synapse_embed::SynapseEmbeddingClient;
11use crate::{slog_info, slog_warn};
12
13use crate::local_embed::LocalEmbedder;
14use rayon::prelude::*;
15use reqwest::blocking::Client;
16use serde::{Deserialize, Serialize};
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::env;
19use std::error::Error;
20use std::fmt::Display;
21use std::fs;
22use std::io::{self, BufReader, BufWriter, Cursor, Read, Write};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex, OnceLock, Weak};
26use std::time::{Duration, Instant, SystemTime};
27use url::Url;
28
29const DEFAULT_DIMENSION: usize = 384;
30const MAX_ENTRIES: usize = 1_000_000;
31// Covers high-dimensional backends such as OpenAI text-embedding-3-large (3072)
32// and common local models (4096) while keeping a bounded supported shape.
33const MAX_DIMENSION: usize = 4096;
34const F32_BYTES: usize = std::mem::size_of::<f32>();
35const HEADER_BYTES_V1: usize = 9;
36const HEADER_BYTES_V2: usize = 13;
37fn begin_semantic_index_build(
38    project_root: &Path,
39) -> (
40    Option<crate::logging::IndexBuildGuard>,
41    crate::logging::IndexBuildScope,
42    crate::logging::IndexBuildFailureGuard,
43) {
44    if let Some(scope) = crate::logging::current_index_build() {
45        if scope.plane == crate::logging::IndexPlane::Semantic {
46            return (None, scope, crate::logging::IndexBuildFailureGuard::new());
47        }
48    }
49    let key = crate::search_index::artifact_cache_key(project_root);
50    let scope = crate::logging::IndexBuildScope::new(
51        crate::logging::IndexPlane::Semantic,
52        project_root,
53        key,
54    );
55    let guard = crate::logging::install_index_build(scope.clone());
56    crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
57        crate::logging::IndexEventKind::BuildStarted,
58        &scope,
59    ));
60    (
61        Some(guard),
62        scope,
63        crate::logging::IndexBuildFailureGuard::new(),
64    )
65}
66
67fn finish_semantic_index_build(
68    scope: &crate::logging::IndexBuildScope,
69    failure_guard: &mut crate::logging::IndexBuildFailureGuard,
70    result: &Result<SemanticIndex, String>,
71) {
72    match result {
73        Ok(index) => {
74            crate::logging::log_index_event(
75                crate::logging::IndexEvent::from_scope(
76                    crate::logging::IndexEventKind::BuildReady,
77                    scope,
78                )
79                .field("elapsed_ms", scope.elapsed_ms())
80                .field("files", index.file_mtimes.len())
81                .field("chunks", index.entries.len()),
82            );
83            failure_guard.disarm();
84        }
85        Err(error) if error.contains("superseded") => {
86            crate::logging::log_index_event(
87                crate::logging::IndexEvent::from_scope(
88                    crate::logging::IndexEventKind::BuildSuperseded,
89                    scope,
90                )
91                .field("stage", "embed"),
92            );
93            failure_guard.disarm();
94        }
95        Err(error) => {
96            crate::logging::log_index_event(
97                crate::logging::IndexEvent::from_scope(
98                    crate::logging::IndexEventKind::BuildFailed,
99                    scope,
100                )
101                .field("reason", error),
102            );
103            failure_guard.disarm();
104        }
105    }
106}
107
108const ONNX_RUNTIME_INSTALL_HINT: &str =
109    "ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
110     apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
111     AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";
112
113const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
114const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
115/// V3 adds subsec_nanos to the file-mtime table so staleness detection survives
116/// restart round-trips on filesystems with subsecond mtime precision (APFS,
117/// ext4 with nsec, NTFS). V1/V2 persisted whole-second mtimes only, which
118/// caused every restart to flag ~99% of files as stale and re-embed them.
119const SEMANTIC_INDEX_VERSION_V3: u8 = 3;
120/// V4 keeps the V3 on-disk layout but rebuilds persisted snippets once after
121/// fixing symbol ranges that were incorrectly treated as 1-based.
122const SEMANTIC_INDEX_VERSION_V4: u8 = 4;
123/// V5 adds file sizes to the file metadata table so incremental staleness
124/// detection can catch content changes even when mtime precision misses them.
125const SEMANTIC_INDEX_VERSION_V5: u8 = 5;
126/// V6 stores paths relative to project_root and adds content hashes.
127const SEMANTIC_INDEX_VERSION_V6: u8 = 6;
128/// V7 adds qualified symbol names for ranking metadata without changing embeddings.
129const SEMANTIC_INDEX_VERSION_V7: u8 = 7;
130const DEFAULT_OPENAI_EMBEDDING_PATH: &str = "/embeddings";
131const DEFAULT_OLLAMA_EMBEDDING_PATH: &str = "/api/embed";
132// Build/refresh embedding requests keep a larger budget because they run on
133// background workers and often batch many texts through a cold local backend.
134const DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS: u64 = 25_000;
135const DEFAULT_MAX_BATCH_SIZE: usize = 64;
136const QUERY_EMBEDDING_CACHE_CAP: usize = 1_000;
137const FALLBACK_BACKEND: &str = "none";
138const EMBEDDING_REQUEST_MAX_ATTEMPTS: usize = 3;
139const EMBEDDING_REQUEST_BACKOFF_MS: [u64; 2] = [500, 1_000];
140static SEMANTIC_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
141
142/// Test-only probe counter for the managed-ONNX resolver (see
143/// `find_managed_onnx_runtime`). Counts storage-tree reads so a negative-control
144/// test can assert a pre-set ORT_DYLIB_PATH short-circuits the resolver.
145#[cfg(test)]
146static MANAGED_ORT_PROBE_READS: AtomicUsize = AtomicUsize::new(0);
147
148/// Per-query request policy kept separate from the background build timeout.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct QueryBudget {
151    timeout_ms: u64,
152}
153
154impl QueryBudget {
155    pub fn from_config(config: &SemanticBackendConfig) -> Self {
156        let configured = if config.query_timeout_ms == 0 {
157            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
158        } else {
159            config.query_timeout_ms
160        };
161        Self {
162            timeout_ms: configured
163                .clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS),
164        }
165    }
166
167    #[cfg(test)]
168    fn timeout_ms(self) -> u64 {
169        self.timeout_ms
170    }
171}
172
173#[derive(Debug, Clone, Copy)]
174enum EmbeddingRequestPolicy {
175    Build,
176    Query(QueryBudget),
177}
178
179impl EmbeddingRequestPolicy {
180    fn max_attempts(self) -> usize {
181        match self {
182            Self::Build => EMBEDDING_REQUEST_MAX_ATTEMPTS,
183            Self::Query(_) => 1,
184        }
185    }
186
187    fn request_timeout(self) -> Option<Duration> {
188        match self {
189            Self::Build => None,
190            Self::Query(budget) => Some(Duration::from_millis(budget.timeout_ms)),
191        }
192    }
193}
194
195pub struct SemanticIndexLock {
196    _guard: Option<fs_lock::LockGuard>,
197}
198
199impl SemanticIndexLock {
200    pub fn acquire(
201        storage_dir: &Path,
202        project_key: &str,
203        project_root: &Path,
204    ) -> std::io::Result<Self> {
205        let dir = storage_dir.join("semantic").join(project_key);
206        let path = dir.join("cache.lock");
207        let access = crate::root_cache::ArtifactAccess::for_root(project_root);
208        if !access.allows_write(project_key, &path) {
209            return Ok(Self { _guard: None });
210        }
211        fs::create_dir_all(&dir)?;
212        let _acquire_guard = SEMANTIC_LOCK_ACQUIRE_MUTEX
213            .lock()
214            .map_err(|_| std::io::Error::other("semantic cache lock acquisition mutex poisoned"))?;
215        fs_lock::try_acquire(&path, Duration::from_secs(2))
216            .map(|guard| Self {
217                _guard: Some(guard),
218            })
219            .map_err(|error| match error {
220                fs_lock::AcquireError::Timeout => {
221                    std::io::Error::other("timed out acquiring semantic cache lock")
222                }
223                fs_lock::AcquireError::Io(error) => error,
224            })
225    }
226}
227
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct SemanticIndexFingerprint {
230    pub backend: String,
231    pub model: String,
232    #[serde(default)]
233    pub base_url: String,
234    pub dimension: usize,
235    #[serde(default = "default_chunking_version")]
236    pub chunking_version: u32,
237    /// The Synapse fingerprint and table epoch identify the served vector space
238    /// so indexes built against incompatible embeddings are rejected.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub synapse_fingerprint: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub synapse_table_epoch: Option<u64>,
243    /// Alternative fingerprints that Synapse explicitly declares equivalent to
244    /// this index's fingerprint, allowing those versions to pass compatibility checks.
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub synapse_equivalent_to: Vec<String>,
247}
248
249fn default_chunking_version() -> u32 {
250    2
251}
252
253impl SemanticIndexFingerprint {
254    fn from_config(config: &SemanticBackendConfig, dimension: usize) -> Self {
255        // Use normalized URL for fingerprinting so cosmetic differences
256        // (e.g. "http://host/v1" vs "http://host/v1/") don't cause rebuilds.
257        let base_url = config
258            .base_url
259            .as_ref()
260            .and_then(|u| normalize_base_url(u).ok())
261            .unwrap_or_else(|| FALLBACK_BACKEND.to_string());
262        Self {
263            backend: config.backend.as_str().to_string(),
264            model: config.model.clone(),
265            base_url,
266            dimension,
267            chunking_version: default_chunking_version(),
268            synapse_fingerprint: None,
269            synapse_table_epoch: None,
270            synapse_equivalent_to: Vec::new(),
271        }
272    }
273
274    pub fn as_string(&self) -> String {
275        serde_json::to_string(self).unwrap_or_else(|_| String::new())
276    }
277
278    pub(crate) fn for_config_dimension(config: &SemanticBackendConfig, dimension: usize) -> Self {
279        Self::from_config(config, dimension)
280    }
281
282    fn matches_expected(&self, expected: &str) -> bool {
283        let Ok(current) = serde_json::from_str::<Self>(expected) else {
284            return false;
285        };
286        self.matches(&current)
287    }
288
289    fn matches(&self, current: &Self) -> bool {
290        if self.backend != current.backend
291            || self.model != current.model
292            || self.base_url != current.base_url
293            || self.dimension != current.dimension
294            || self.chunking_version != current.chunking_version
295            || self.synapse_table_epoch != current.synapse_table_epoch
296        {
297            return false;
298        }
299        match (&self.synapse_fingerprint, &current.synapse_fingerprint) {
300            (None, None) => true,
301            (Some(cached), Some(served)) => {
302                cached == served
303                    || current
304                        .synapse_equivalent_to
305                        .iter()
306                        .any(|alias| alias == cached)
307                    || self
308                        .synapse_equivalent_to
309                        .iter()
310                        .any(|alias| alias == served)
311            }
312            _ => false,
313        }
314    }
315}
316
317fn redacted_base_url_host(base_url: &str) -> String {
318    if base_url.is_empty() {
319        return "<empty>".to_string();
320    }
321    if base_url == FALLBACK_BACKEND {
322        return FALLBACK_BACKEND.to_string();
323    }
324
325    match Url::parse(base_url) {
326        Ok(parsed) => {
327            let host = parsed.host_str().unwrap_or("<missing-host>");
328            match parsed.port() {
329                Some(port) => format!("{host}:{port}"),
330                None => host.to_string(),
331            }
332        }
333        Err(_) => "<invalid>".to_string(),
334    }
335}
336
337fn format_fingerprint_mismatch_details(
338    cached: Option<&SemanticIndexFingerprint>,
339    current: &SemanticIndexFingerprint,
340) -> String {
341    let Some(cached) = cached else {
342        return format!(
343            "cached fingerprint missing; current backend kind={}, model={}, base_url host={}, dimension={}, chunking version={}",
344            current.backend,
345            current.model,
346            redacted_base_url_host(&current.base_url),
347            current.dimension,
348            current.chunking_version,
349        );
350    };
351
352    let mut diffs = Vec::new();
353    if cached.backend != current.backend {
354        diffs.push(format!(
355            "backend kind cached={} current={}",
356            cached.backend, current.backend
357        ));
358    }
359    if cached.model != current.model {
360        diffs.push(format!(
361            "model cached={} current={}",
362            cached.model, current.model
363        ));
364    }
365    if cached.base_url != current.base_url {
366        let cached_host = redacted_base_url_host(&cached.base_url);
367        let current_host = redacted_base_url_host(&current.base_url);
368        if cached_host == current_host {
369            diffs.push(format!(
370                "base_url host cached={} current={} (credentials/path redacted)",
371                cached_host, current_host
372            ));
373        } else {
374            diffs.push(format!(
375                "base_url host cached={} current={}",
376                cached_host, current_host
377            ));
378        }
379    }
380    if cached.dimension != current.dimension {
381        diffs.push(format!(
382            "dimension cached={} current={}",
383            cached.dimension, current.dimension
384        ));
385    }
386    if cached.chunking_version != current.chunking_version {
387        diffs.push(format!(
388            "chunking version cached={} current={}",
389            cached.chunking_version, current.chunking_version
390        ));
391    }
392    if cached.synapse_table_epoch != current.synapse_table_epoch {
393        diffs.push(format!(
394            "synapse table_epoch cached={:?} current={:?}",
395            cached.synapse_table_epoch, current.synapse_table_epoch
396        ));
397    }
398    if !cached.matches(current)
399        && (cached.synapse_fingerprint.is_some() || current.synapse_fingerprint.is_some())
400    {
401        diffs.push(format!(
402            "synapse fingerprint cached={} current={} (equivalence class checked)",
403            cached.synapse_fingerprint.as_deref().unwrap_or("<missing>"),
404            current
405                .synapse_fingerprint
406                .as_deref()
407                .unwrap_or("<missing>")
408        ));
409    }
410
411    if diffs.is_empty() {
412        "fingerprint strings differ but parsed fields match".to_string()
413    } else {
414        diffs.join("; ")
415    }
416}
417
418fn log_fingerprint_mismatch(cached: Option<&SemanticIndexFingerprint>, expected: &str) {
419    match serde_json::from_str::<SemanticIndexFingerprint>(expected) {
420        Ok(current) => slog_warn!(
421            "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: {}",
422            format_fingerprint_mismatch_details(cached, &current)
423        ),
424        Err(error) => slog_warn!(
425            "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: could not parse current fingerprint: {}",
426            error
427        ),
428    }
429}
430
431enum SemanticEmbeddingEngine {
432    /// Local ONNX embedder (all-MiniLM-L6-v2 via raw `ort`). The config-facing
433    /// backend string stays "fastembed" for index-fingerprint compatibility.
434    Local(LocalEmbedder),
435    OpenAiCompatible {
436        client: Client,
437        model: String,
438        base_url: String,
439        api_key: Option<String>,
440    },
441    Ollama {
442        client: Client,
443        model: String,
444        base_url: String,
445    },
446    Synapse(SynapseEmbeddingClient),
447}
448
449pub struct SemanticEmbeddingModel {
450    backend: SemanticBackend,
451    model: String,
452    base_url: Option<String>,
453    timeout_ms: u64,
454    max_batch_size: usize,
455    dimension: Option<usize>,
456    engine: SemanticEmbeddingEngine,
457    query_embedding_cache: HashMap<String, Vec<f32>>,
458    query_embedding_cache_order: VecDeque<String>,
459    query_embedding_cache_hits: u64,
460    query_embedding_cache_misses: u64,
461}
462
463pub type EmbeddingModel = SemanticEmbeddingModel;
464
465fn validate_embedding_batch(
466    vectors: &[Vec<f32>],
467    expected_count: usize,
468    context: &str,
469) -> Result<(), String> {
470    if expected_count > 0 && vectors.is_empty() {
471        return Err(format!(
472            "{context} returned no vectors for {expected_count} inputs"
473        ));
474    }
475
476    if vectors.len() != expected_count {
477        return Err(format!(
478            "{context} returned {} vectors for {} inputs",
479            vectors.len(),
480            expected_count
481        ));
482    }
483
484    let Some(first_vector) = vectors.first() else {
485        return Ok(());
486    };
487    let expected_dimension = first_vector.len();
488    validate_embedding_dimension(expected_dimension)
489        .map_err(|error| format!("{context} returned {error}"))?;
490    for (index, vector) in vectors.iter().enumerate() {
491        if vector.len() != expected_dimension {
492            return Err(format!(
493                "{context} returned inconsistent embedding dimensions: vector 0 has length {expected_dimension}, vector {index} has length {}",
494                vector.len()
495            ));
496        }
497    }
498
499    Ok(())
500}
501
502fn validate_embedding_dimension(dimension: usize) -> Result<(), String> {
503    if dimension == 0 || dimension > MAX_DIMENSION {
504        return Err(format!(
505            "invalid embedding dimension: {dimension}; supported range is 1..={MAX_DIMENSION}"
506        ));
507    }
508
509    Ok(())
510}
511
512/// Normalize a base URL: validate scheme and strip trailing slash.
513/// Does NOT perform SSRF/private-IP validation — call
514/// `validate_base_url_no_ssrf` separately when processing user-supplied config.
515fn normalize_base_url(raw: &str) -> Result<String, String> {
516    let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
517    let scheme = parsed.scheme();
518    if scheme != "http" && scheme != "https" {
519        return Err(format!(
520            "unsupported URL scheme '{}' — only http:// and https:// are allowed",
521            scheme
522        ));
523    }
524    Ok(parsed.to_string().trim_end_matches('/').to_string())
525}
526
527/// Validate that a base URL does not point to a private/loopback address.
528/// Call this on user-supplied config (at configure time) to prevent SSRF.
529/// Not called for programmatically constructed configs (e.g. tests).
530///
531/// **Loopback is allowed.** Self-hosted embedding backends (e.g. Ollama at
532/// `http://127.0.0.1:11434`) are a primary use case for `aft_search`. Loopback
533/// addresses by definition cannot be exploited as SSRF targets — they only
534/// reach services on the same machine. Allowing loopback unblocks Ollama at its
535/// default config without opening up SSRF to LAN/intranet services, which
536/// remain rejected.
537///
538/// **mDNS `.local` is rejected.** mDNS hostnames typically resolve to LAN
539/// devices (printers, homelab servers); rejecting them before DNS lookup keeps
540/// the SSRF guard meaningful for non-loopback private networks.
541pub fn validate_base_url_no_ssrf(raw: &str) -> Result<(), String> {
542    use std::net::{IpAddr, ToSocketAddrs};
543
544    let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
545
546    let host = parsed.host_str().unwrap_or("");
547
548    // Loopback hostnames are explicitly allowed. RFC 6761 mandates that
549    // `localhost` and `*.localhost` resolve to loopback;
550    // `localhost.localdomain` is a historical alias used on some Linux
551    // distros. Self-hosted backends like Ollama use these by default.
552    let is_loopback_host =
553        host == "localhost" || host == "localhost.localdomain" || host.ends_with(".localhost");
554    if is_loopback_host {
555        return Ok(());
556    }
557
558    // mDNS hostnames are typically LAN devices, not loopback. Reject before
559    // DNS lookup so users get a clear error rather than a private-IP error.
560    if host.ends_with(".local") {
561        return Err(format!(
562            "base_url host '{host}' is an mDNS name — only loopback (localhost / 127.0.0.1) and public endpoints are allowed"
563        ));
564    }
565
566    // Resolve the hostname. Reject private/link-local/CGNAT IPs but NOT
567    // loopback (which is by definition same-machine and not an SSRF target).
568    let port = parsed.port_or_known_default().unwrap_or(443);
569    let addr_str = format!("{host}:{port}");
570    let addrs: Vec<IpAddr> = addr_str
571        .to_socket_addrs()
572        .map(|iter| iter.map(|sa| sa.ip()).collect())
573        .unwrap_or_default();
574    for ip in &addrs {
575        if is_private_non_loopback_ip(ip) {
576            return Err(format!(
577                "base_url '{raw}' resolves to a private/reserved IP — only loopback (127.0.0.1) and public endpoints are allowed"
578            ));
579        }
580    }
581
582    Ok(())
583}
584
585/// Returns true for IPv4/IPv6 addresses in private/link-local/CGNAT/benchmark/
586/// multicast/reserved ranges, EXCLUDING loopback (127.0.0.0/8 and ::1). Loopback
587/// is considered safe for SSRF purposes (same-machine, e.g. a local Ollama
588/// endpoint) — see [`validate_base_url_no_ssrf`] for rationale.
589///
590/// Delegates to [`crate::url_fetch::is_private_or_reserved_ip`] so there is one
591/// authoritative reserved-range list (the url_fetch copy is the maintained one;
592/// this used to be a drifting subset that missed e.g. 198.18.0.0/15 and the
593/// multicast/reserved blocks). We only re-add the loopback carve-out the
594/// url_fetch guard deliberately does not make.
595fn is_private_non_loopback_ip(ip: &std::net::IpAddr) -> bool {
596    // Canonicalize so an IPv4-mapped loopback (`::ffff:127.0.0.1`) is also
597    // recognized as loopback, matching the prior carve-out.
598    if ip.to_canonical().is_loopback() {
599        return false;
600    }
601    crate::url_fetch::is_private_or_reserved_ip(*ip)
602}
603
604fn build_openai_embeddings_endpoint(base_url: &str) -> String {
605    if base_url.ends_with("/v1") {
606        format!("{base_url}{DEFAULT_OPENAI_EMBEDDING_PATH}")
607    } else {
608        format!("{base_url}/v1{}", DEFAULT_OPENAI_EMBEDDING_PATH)
609    }
610}
611
612fn build_ollama_embeddings_endpoint(base_url: &str) -> String {
613    if base_url.ends_with("/api") {
614        format!("{base_url}/embed")
615    } else {
616        format!("{base_url}{DEFAULT_OLLAMA_EMBEDDING_PATH}")
617    }
618}
619
620fn normalize_api_key(value: Option<String>) -> Option<String> {
621    value.and_then(|token| {
622        let token = token.trim();
623        if token.is_empty() {
624            None
625        } else {
626            Some(token.to_string())
627        }
628    })
629}
630
631fn is_retryable_embedding_status(status: reqwest::StatusCode) -> bool {
632    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
633}
634
635/// Local backends (LM Studio, Ollama, llama.cpp) can return a 4xx — usually
636/// 400/409 — while a model is loading or was just unloaded. Only narrowly known
637/// local-backend loading/unloaded payloads are classified transient; generic
638/// 4xx bodies that merely mention phrases like "loading model" remain
639/// permanent so misconfigurations do not retry forever.
640fn embedding_response_body_is_transient(status: reqwest::StatusCode, raw: &str) -> bool {
641    if !matches!(
642        status,
643        reqwest::StatusCode::BAD_REQUEST
644            | reqwest::StatusCode::CONFLICT
645            | reqwest::StatusCode::REQUEST_TIMEOUT
646            | reqwest::StatusCode::LOCKED
647            | reqwest::StatusCode::TOO_EARLY
648    ) {
649        return false;
650    }
651
652    let lower = raw.to_ascii_lowercase();
653    let normalized = lower.trim();
654
655    normalized.contains("model was unloaded while the request was still in queue")
656        || normalized == "model is loading"
657        || normalized.starts_with("model is loading,")
658        || normalized.contains(r#""error":"model is loading"#)
659        || normalized.contains(r#""message":"model is loading"#)
660        || normalized == "model not loaded"
661        || normalized.contains(r#""error":"model not loaded""#)
662        || normalized.contains(r#""message":"model not loaded""#)
663        || normalized == "loading model into memory"
664        || normalized.contains(r#""error":"loading model into memory""#)
665        || normalized.contains(r#""message":"loading model into memory""#)
666        || normalized == "model is being loaded"
667        || normalized.contains(r#""error":"model is being loaded""#)
668        || normalized.contains(r#""message":"model is being loaded""#)
669        || normalized == "model is currently loading"
670        || normalized.contains(r#""error":"model is currently loading""#)
671        || normalized.contains(r#""message":"model is currently loading""#)
672}
673
674fn is_retryable_embedding_error(error: &reqwest::Error) -> bool {
675    // Retryable == transient-at-send-stage: a backend that refused, timed
676    // out, or died mid-exchange deserves the same in-request retry ladder.
677    embedding_send_error_is_transient(error)
678}
679
680/// Whether a send-time error means the backend is *unreachable or temporarily
681/// failing* (vs. a real misconfiguration). Build requests retry both connection
682/// failures and timeouts; query requests use the same classification but have a
683/// one-attempt policy.
684fn embedding_send_error_is_transient(error: &reqwest::Error) -> bool {
685    // TLS trust failures are reported by reqwest as connect errors, but they
686    // cannot recover by retrying. Check the source chain before the broad
687    // connect/timeout classification so private-CA failures become terminal.
688    if embedding_error_is_certificate_trust_failure(error) {
689        return false;
690    }
691    if error.is_connect() || error.is_timeout() {
692        return true;
693    }
694    // A connection reset/abort mid-request is the backend dying between
695    // accept and response (local backends do this when they crash or restart
696    // under load) — the same "temporarily failing" class as a refused
697    // connection, just later in the exchange. reqwest surfaces it as a plain
698    // send error. Classify from the io source chain where one exists; hyper
699    // errors like IncompleteMessage ("connection closed before message
700    // completed") carry no io source, so fall back to known phrases in the
701    // chain's rendered messages.
702    let mut source = std::error::Error::source(error);
703    while let Some(inner) = source {
704        if let Some(io) = inner.downcast_ref::<std::io::Error>() {
705            if matches!(
706                io.kind(),
707                std::io::ErrorKind::ConnectionReset
708                    | std::io::ErrorKind::ConnectionAborted
709                    | std::io::ErrorKind::BrokenPipe
710                    | std::io::ErrorKind::UnexpectedEof
711            ) {
712                return true;
713            }
714        }
715        let rendered = inner.to_string().to_ascii_lowercase();
716        if rendered.contains("connection reset")
717            || rendered.contains("connection aborted")
718            || rendered.contains("connection closed")
719            || rendered.contains("broken pipe")
720            || rendered.contains("unexpected end of file")
721        {
722            return true;
723        }
724        source = std::error::Error::source(inner);
725    }
726    false
727}
728
729fn render_error_source_chain(error: &dyn Error) -> String {
730    let mut rendered = error.to_string();
731    let mut source = error.source();
732    while let Some(cause) = source {
733        rendered.push_str(": ");
734        rendered.push_str(&cause.to_string());
735        source = cause.source();
736    }
737    rendered
738}
739
740fn embedding_error_is_certificate_trust_failure(error: &reqwest::Error) -> bool {
741    let rendered = render_error_source_chain(error).to_ascii_lowercase();
742    [
743        "unknownissuer",
744        "unknown issuer",
745        "invalid peer certificate",
746        "certificate verify failed",
747        "certificate validation failed",
748        "certificate error",
749    ]
750    .iter()
751    .any(|marker| rendered.contains(marker))
752}
753
754fn embedding_response_read_error_is_transient(error: &reqwest::Error) -> bool {
755    embedding_send_error_is_transient(error) || error.is_body() || error.is_decode()
756}
757
758/// Returns the query-timeout marker for a request error when the active policy
759/// is a `Query(budget)` and reqwest classifies the error as a timeout. Returns
760/// an empty string otherwise — build-policy timeouts and non-timeout query
761/// errors carry no marker. This is the single site that decides whether a
762/// failure is "the configured query budget fired", so the fallback message can
763/// name the knob (`semantic.query_timeout_ms`) without re-parsing reqwest text.
764fn query_timeout_marker_for_error(
765    error: &reqwest::Error,
766    policy: EmbeddingRequestPolicy,
767) -> String {
768    match policy {
769        EmbeddingRequestPolicy::Query(budget) if error.is_timeout() => {
770            query_embedding_timeout_marker(budget.timeout_ms)
771        }
772        _ => String::new(),
773    }
774}
775
776/// Stable machine marker prefixed onto embedding error strings whose root cause
777/// is transient — the backend is down, timing out, or returning 5xx/429, not
778/// misconfigured. The build and corpus-refresh layers key retry-vs-give-up on
779/// this marker (see [`embedding_failure_is_transient`]) instead of re-parsing
780/// error text, so transience stays authoritative at the one site that knows it.
781/// Stripped before any user-facing display via [`strip_transient_embedding_marker`].
782pub const TRANSIENT_EMBEDDING_MARKER: &str = "[transient] ";
783
784/// True when an embedding error carries the transient marker — i.e. retrying
785/// once the backend recovers is the right move, not surfacing a hard failure.
786pub fn embedding_failure_is_transient(error: &str) -> bool {
787    error.contains(TRANSIENT_EMBEDDING_MARKER)
788}
789
790/// Remove the machine transient marker so the message is clean for display.
791pub fn strip_transient_embedding_marker(error: &str) -> String {
792    error.replace(TRANSIENT_EMBEDDING_MARKER, "")
793}
794
795/// Stable machine marker prefixed onto a *query* embedding error string when
796/// the failure was a request timeout — i.e. reqwest's `is_timeout()` fired
797/// while running under a `Query(budget)` policy. The marker carries the budget
798/// that fired (`[query-timeout:{ms}]`) so the consumer can name both the
799/// mechanism and the knob (`semantic.query_timeout_ms`) without re-parsing
800/// reqwest's rendered error text, which varies by backend and locale.
801///
802/// Classification lives here — next to the one site that knows both the policy
803/// (Query with a budget) and the typed reqwest error — so it cannot drift from
804/// the error shape. Stripped before user-facing display via
805/// [`strip_query_embedding_timeout_marker`]; the budget is recovered via
806/// [`query_embedding_timeout_budget`].
807pub const QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[query-timeout:";
808pub const QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
809
810/// Build the timeout marker for a given query budget. Kept here so the format
811/// and the parser below stay in lockstep. `pub(crate)` so the classification
812/// test in `semantic_search` can construct a marked error without duplicating
813/// the format string.
814pub(crate) fn query_embedding_timeout_marker(timeout_ms: u64) -> String {
815    format!("{QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX}{timeout_ms}{QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX}")
816}
817
818/// Recover the timeout budget (ms) a query embedding error carries, or `None`
819/// when the failure was not a query timeout. This is the single authoritative
820/// way to detect the timeout case — never substring-match on reqwest's text.
821pub fn query_embedding_timeout_budget(error: &str) -> Option<u64> {
822    let start = error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX)?;
823    let rest = &error[start + QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX.len()..];
824    let end = rest.find(QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX)?;
825    rest[..end].parse::<u64>().ok()
826}
827
828/// Remove the query-timeout marker so the message is clean for display. The
829/// budget is recovered separately via [`query_embedding_timeout_budget`] before
830/// stripping.
831pub fn strip_query_embedding_timeout_marker(error: &str) -> String {
832    if let (Some(start), Some(budget)) = (
833        error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX),
834        query_embedding_timeout_budget(error),
835    ) {
836        let marker = query_embedding_timeout_marker(budget);
837        let end = start + marker.len();
838        let mut cleaned = error.to_string();
839        cleaned.replace_range(start..end, "");
840        cleaned
841    } else {
842        error.to_string()
843    }
844}
845
846fn sleep_before_embedding_retry(attempt_index: usize) {
847    if let Some(delay_ms) = EMBEDDING_REQUEST_BACKOFF_MS.get(attempt_index) {
848        std::thread::sleep(Duration::from_millis(*delay_ms));
849    }
850}
851
852const QUERY_EMBEDDING_CANCELLED_MARKER: &str = "__AFT_QUERY_EMBEDDING_CANCELLED__";
853const QUERY_EMBEDDING_CANCEL_POLL: Duration = Duration::from_millis(10);
854
855enum EmbeddingExchange {
856    SendFailed(reqwest::Error),
857    Response {
858        status: reqwest::StatusCode,
859        body: Result<String, reqwest::Error>,
860    },
861}
862
863fn execute_embedding_exchange(request: reqwest::blocking::RequestBuilder) -> EmbeddingExchange {
864    match request.send() {
865        Ok(response) => EmbeddingExchange::Response {
866            status: response.status(),
867            body: response.text(),
868        },
869        Err(error) => EmbeddingExchange::SendFailed(error),
870    }
871}
872
873fn execute_query_embedding_exchange(
874    request: reqwest::blocking::RequestBuilder,
875) -> Result<EmbeddingExchange, String> {
876    let Some(cancellation) = crate::executor::current_job_cancellation() else {
877        return Ok(execute_embedding_exchange(request));
878    };
879    if cancellation.cancel_requested_before_commit() {
880        return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
881    }
882
883    let (tx, rx) = crossbeam_channel::bounded(1);
884    std::thread::spawn(move || {
885        let _ = tx.send(execute_embedding_exchange(request));
886    });
887    loop {
888        match rx.try_recv() {
889            Ok(exchange) => {
890                if cancellation.cancel_requested_before_commit() {
891                    return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
892                }
893                return Ok(exchange);
894            }
895            Err(crossbeam_channel::TryRecvError::Disconnected) => {
896                return Err("embedding request worker disconnected".to_string());
897            }
898            Err(crossbeam_channel::TryRecvError::Empty) => {}
899        }
900        if cancellation.wait_for_cancellation(QUERY_EMBEDDING_CANCEL_POLL) {
901            return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
902        }
903    }
904}
905
906fn send_embedding_request<F>(
907    mut make_request: F,
908    backend_label: &str,
909    policy: EmbeddingRequestPolicy,
910) -> Result<String, String>
911where
912    F: FnMut() -> reqwest::blocking::RequestBuilder,
913{
914    let max_attempts = policy.max_attempts();
915    for attempt_index in 0..max_attempts {
916        let last_attempt = attempt_index + 1 == max_attempts;
917        let mut request = make_request();
918        if let Some(timeout) = policy.request_timeout() {
919            request = request.timeout(timeout);
920        }
921
922        let exchange = match policy {
923            EmbeddingRequestPolicy::Build => execute_embedding_exchange(request),
924            EmbeddingRequestPolicy::Query(_) => execute_query_embedding_exchange(request)?,
925        };
926        let (status, raw) = match exchange {
927            EmbeddingExchange::SendFailed(error) => {
928                if !last_attempt && is_retryable_embedding_error(&error) {
929                    sleep_before_embedding_retry(attempt_index);
930                    continue;
931                }
932                // Connect/timeout failures mean the backend is unreachable or
933                // cold-loading — mark transient so the build layer rides it out
934                // and self-heals instead of parking the index in `Failed`.
935                let marker = if embedding_send_error_is_transient(&error) {
936                    TRANSIENT_EMBEDDING_MARKER
937                } else {
938                    ""
939                };
940                // A query-timeout is a distinct, actionable failure: the
941                // configured `semantic.query_timeout_ms` budget fired. Tag it
942                // here — the only site that has both the typed reqwest error
943                // and the Query budget — so the fallback can name the knob
944                // without guessing at reqwest's rendered text.
945                let timeout_marker = query_timeout_marker_for_error(&error, policy);
946                return Err(format!(
947                    "{timeout_marker}{marker}{backend_label} request failed: {}",
948                    render_error_source_chain(&error)
949                ));
950            }
951            EmbeddingExchange::Response {
952                status,
953                body: Ok(raw),
954            } => (status, raw),
955            EmbeddingExchange::Response {
956                status: _,
957                body: Err(error),
958            } => {
959                if !last_attempt && embedding_response_read_error_is_transient(&error) {
960                    sleep_before_embedding_retry(attempt_index);
961                    continue;
962                }
963                let marker = if embedding_response_read_error_is_transient(&error) {
964                    TRANSIENT_EMBEDDING_MARKER
965                } else {
966                    ""
967                };
968                // A body-read timeout under a Query policy is the same budget
969                // firing mid-exchange; tag it identically to the send case.
970                let timeout_marker = query_timeout_marker_for_error(&error, policy);
971                return Err(format!(
972                    "{timeout_marker}{marker}{backend_label} response read failed: {}",
973                    render_error_source_chain(&error)
974                ));
975            }
976        };
977
978        if status.is_success() {
979            return Ok(raw);
980        }
981
982        // A 4xx whose body says the model is loading/unloaded is transient on
983        // local backends (LM Studio/Ollama), so treat it like a retryable
984        // status: ride it out at both the in-request and build-retry layers.
985        let body_transient = embedding_response_body_is_transient(status, &raw);
986        if !last_attempt && (is_retryable_embedding_status(status) || body_transient) {
987            sleep_before_embedding_retry(attempt_index);
988            continue;
989        }
990
991        // 5xx / 429 are server-side and transient — the backend is overloaded
992        // or briefly unavailable, not misconfigured. A 4xx whose body indicates
993        // the model is (un)loading is also transient (local backend mid-swap).
994        // Other 4xx (auth, bad request, model-not-found) is a real error the
995        // user must fix; no marker.
996        let marker = if is_retryable_embedding_status(status) || body_transient {
997            TRANSIENT_EMBEDDING_MARKER
998        } else {
999            ""
1000        };
1001        return Err(format!(
1002            "{marker}{backend_label} request failed (HTTP {}): {}",
1003            status, raw
1004        ));
1005    }
1006
1007    unreachable!("embedding request retries exhausted without returning")
1008}
1009
1010fn configured_embedding_timeout_ms(config: &SemanticBackendConfig) -> u64 {
1011    if config.timeout_ms == 0 {
1012        DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS
1013    } else {
1014        config.timeout_ms
1015    }
1016}
1017
1018impl SemanticEmbeddingModel {
1019    pub fn from_config(config: &SemanticBackendConfig) -> Result<Self, String> {
1020        Self::from_config_with_timeout_ms(config, configured_embedding_timeout_ms(config))
1021    }
1022
1023    pub fn from_config_for_query(config: &SemanticBackendConfig) -> Result<Self, String> {
1024        // The model may later be reused by a background build, so retain the build
1025        // client's timeout. QueryBudget overrides each interactive HTTP request.
1026        Self::from_config(config)
1027    }
1028
1029    fn from_config_with_timeout_ms(
1030        config: &SemanticBackendConfig,
1031        timeout_ms: u64,
1032    ) -> Result<Self, String> {
1033        let max_batch_size = if config.max_batch_size == 0 {
1034            DEFAULT_MAX_BATCH_SIZE
1035        } else {
1036            config.max_batch_size
1037        };
1038
1039        let api_key_env = normalize_api_key(config.api_key_env.clone());
1040        let model = config.model.clone();
1041
1042        let tls_config = crate::platform_tls::client_config()
1043            .map_err(|error| format!("failed to configure embedding client TLS: {error}"))?;
1044        let client = Client::builder()
1045            .timeout(Duration::from_millis(timeout_ms))
1046            .redirect(reqwest::redirect::Policy::none())
1047            .use_preconfigured_tls(tls_config)
1048            .build()
1049            .map_err(|error| format!("failed to configure embedding client: {error}"))?;
1050
1051        let engine = match config.backend {
1052            SemanticBackend::Fastembed => {
1053                SemanticEmbeddingEngine::Local(LocalEmbedder::new(&model)?)
1054            }
1055            SemanticBackend::OpenAiCompatible => {
1056                let raw = config.base_url.as_ref().ok_or_else(|| {
1057                    "base_url is required for openai_compatible backend".to_string()
1058                })?;
1059                let base_url = normalize_base_url(raw)?;
1060
1061                let api_key = match api_key_env {
1062                    Some(var_name) => Some(env::var(&var_name).map_err(|_| {
1063                        format!("missing api_key_env '{var_name}' for openai_compatible backend")
1064                    })?),
1065                    None => None,
1066                };
1067
1068                SemanticEmbeddingEngine::OpenAiCompatible {
1069                    client,
1070                    model,
1071                    base_url,
1072                    api_key,
1073                }
1074            }
1075            SemanticBackend::Ollama => {
1076                let raw = config
1077                    .base_url
1078                    .as_ref()
1079                    .ok_or_else(|| "base_url is required for ollama backend".to_string())?;
1080                let base_url = normalize_base_url(raw)?;
1081
1082                SemanticEmbeddingEngine::Ollama {
1083                    client,
1084                    model,
1085                    base_url,
1086                }
1087            }
1088            SemanticBackend::Synapse => SemanticEmbeddingEngine::Synapse(
1089                SynapseEmbeddingClient::from_config(config).map_err(|error| error.to_string())?,
1090            ),
1091        };
1092        let max_batch_size = match &engine {
1093            SemanticEmbeddingEngine::Synapse(client) => client.metadata().recommended_rows,
1094            _ => max_batch_size,
1095        };
1096
1097        Ok(Self {
1098            backend: config.backend,
1099            model: config.model.clone(),
1100            base_url: config.base_url.clone(),
1101            timeout_ms,
1102            max_batch_size,
1103            dimension: None,
1104            engine,
1105            query_embedding_cache: HashMap::new(),
1106            query_embedding_cache_order: VecDeque::new(),
1107            query_embedding_cache_hits: 0,
1108            query_embedding_cache_misses: 0,
1109        })
1110    }
1111
1112    pub fn backend(&self) -> SemanticBackend {
1113        self.backend
1114    }
1115
1116    pub fn model(&self) -> &str {
1117        &self.model
1118    }
1119
1120    pub fn base_url(&self) -> Option<&str> {
1121        self.base_url.as_deref()
1122    }
1123
1124    pub fn max_batch_size(&self) -> usize {
1125        self.max_batch_size
1126    }
1127
1128    pub fn timeout_ms(&self) -> u64 {
1129        self.timeout_ms
1130    }
1131
1132    pub fn fingerprint(
1133        &mut self,
1134        config: &SemanticBackendConfig,
1135    ) -> Result<SemanticIndexFingerprint, String> {
1136        let dimension = self.dimension()?;
1137        let mut fingerprint = SemanticIndexFingerprint::from_config(config, dimension);
1138        if let SemanticEmbeddingEngine::Synapse(client) = &self.engine {
1139            let identity = client.identity();
1140            fingerprint.synapse_fingerprint = Some(identity.fingerprint.clone());
1141            fingerprint.synapse_table_epoch = Some(identity.table_epoch);
1142            fingerprint.synapse_equivalent_to = identity.equivalent_to.clone();
1143        }
1144        Ok(fingerprint)
1145    }
1146
1147    pub fn dimension(&mut self) -> Result<usize, String> {
1148        if let Some(dimension) = self.dimension {
1149            return Ok(dimension);
1150        }
1151
1152        let dimension = match &mut self.engine {
1153            SemanticEmbeddingEngine::Local(model) => {
1154                let vectors = model.embed(&["semantic index fingerprint probe".to_string()])?;
1155                vectors
1156                    .first()
1157                    .map(|v| v.len())
1158                    .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1159            }
1160            SemanticEmbeddingEngine::OpenAiCompatible { .. } => {
1161                let vectors = self.embed_texts(
1162                    vec!["semantic index fingerprint probe".to_string()],
1163                    EmbeddingRequestPolicy::Build,
1164                )?;
1165                vectors
1166                    .first()
1167                    .map(|v| v.len())
1168                    .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1169            }
1170            SemanticEmbeddingEngine::Ollama { .. } => {
1171                let vectors = self.embed_texts(
1172                    vec!["semantic index fingerprint probe".to_string()],
1173                    EmbeddingRequestPolicy::Build,
1174                )?;
1175                vectors
1176                    .first()
1177                    .map(|v| v.len())
1178                    .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1179            }
1180            SemanticEmbeddingEngine::Synapse(client) => client
1181                .probe_dimension(Duration::from_millis(self.timeout_ms))
1182                .map_err(|error| error.to_string())?,
1183        };
1184
1185        self.dimension = Some(dimension);
1186        Ok(dimension)
1187    }
1188
1189    pub fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
1190        self.embed_texts(texts, EmbeddingRequestPolicy::Build)
1191    }
1192
1193    pub fn embed_query_cached(
1194        &mut self,
1195        query: &str,
1196        budget: QueryBudget,
1197    ) -> Result<Vec<f32>, String> {
1198        if let Some(vector) = self.query_embedding_cache.get(query) {
1199            self.query_embedding_cache_hits += 1;
1200            return Ok(vector.clone());
1201        }
1202
1203        self.query_embedding_cache_misses += 1;
1204        let embeddings = self.embed_texts(
1205            vec![query.to_string()],
1206            EmbeddingRequestPolicy::Query(budget),
1207        )?;
1208        let vector = embeddings
1209            .first()
1210            .cloned()
1211            .ok_or_else(|| "embedding model returned no query vector".to_string())?;
1212
1213        if self.query_embedding_cache.len() >= QUERY_EMBEDDING_CACHE_CAP {
1214            if let Some(oldest) = self.query_embedding_cache_order.pop_front() {
1215                self.query_embedding_cache.remove(&oldest);
1216            }
1217        }
1218        self.query_embedding_cache
1219            .insert(query.to_string(), vector.clone());
1220        self.query_embedding_cache_order
1221            .push_back(query.to_string());
1222
1223        Ok(vector)
1224    }
1225
1226    pub fn query_embedding_cache_stats(&self) -> (u64, u64, usize) {
1227        (
1228            self.query_embedding_cache_hits,
1229            self.query_embedding_cache_misses,
1230            self.query_embedding_cache.len(),
1231        )
1232    }
1233
1234    fn embed_texts(
1235        &mut self,
1236        texts: Vec<String>,
1237        policy: EmbeddingRequestPolicy,
1238    ) -> Result<Vec<Vec<f32>>, String> {
1239        match &mut self.engine {
1240            SemanticEmbeddingEngine::Local(model) => model
1241                .embed(&texts)
1242                .map_err(|error| format!("failed to embed batch: {error}")),
1243            SemanticEmbeddingEngine::OpenAiCompatible {
1244                client,
1245                model,
1246                base_url,
1247                api_key,
1248            } => {
1249                let expected_text_count = texts.len();
1250                let endpoint = build_openai_embeddings_endpoint(base_url);
1251                let body = serde_json::json!({
1252                    "input": texts,
1253                    "model": model,
1254                });
1255
1256                let raw = send_embedding_request(
1257                    || {
1258                        // `.json(&body)` sets Content-Type: application/json
1259                        // automatically. Do NOT add `.header("Content-Type",
1260                        // "application/json")` afterwards — RequestBuilder::header()
1261                        // calls HeaderMap::append, which produces TWO Content-Type
1262                        // headers on the wire. OpenAI's /v1/embeddings endpoint
1263                        // treats duplicate Content-Type as malformed and rejects
1264                        // the body with 400 "you must provide a model parameter"
1265                        // even when `model` is set. Verified end-to-end against
1266                        // api.openai.com. See issue #36.
1267                        let mut request = client.post(&endpoint).json(&body);
1268
1269                        if let Some(api_key) = api_key {
1270                            request = request.header("Authorization", format!("Bearer {api_key}"));
1271                        }
1272
1273                        request
1274                    },
1275                    "openai compatible",
1276                    policy,
1277                )?;
1278
1279                #[derive(Deserialize)]
1280                struct OpenAiResponse {
1281                    data: Vec<OpenAiEmbeddingResult>,
1282                }
1283
1284                #[derive(Deserialize)]
1285                struct OpenAiEmbeddingResult {
1286                    embedding: Vec<f32>,
1287                    index: Option<u32>,
1288                }
1289
1290                let parsed: OpenAiResponse = serde_json::from_str(&raw)
1291                    .map_err(|error| format!("invalid openai compatible response: {error}"))?;
1292                if parsed.data.len() != expected_text_count {
1293                    return Err(format!(
1294                        "openai compatible response returned {} embeddings for {} inputs",
1295                        parsed.data.len(),
1296                        expected_text_count
1297                    ));
1298                }
1299
1300                let mut vectors = vec![Vec::new(); parsed.data.len()];
1301                for (i, item) in parsed.data.into_iter().enumerate() {
1302                    let index = item.index.unwrap_or(i as u32) as usize;
1303                    if index >= vectors.len() {
1304                        return Err(
1305                            "openai compatible response contains invalid vector index".to_string()
1306                        );
1307                    }
1308                    vectors[index] = item.embedding;
1309                }
1310
1311                for vector in &vectors {
1312                    if vector.is_empty() {
1313                        return Err(
1314                            "openai compatible response contained missing vectors".to_string()
1315                        );
1316                    }
1317                }
1318
1319                self.dimension = vectors.first().map(Vec::len);
1320                Ok(vectors)
1321            }
1322            SemanticEmbeddingEngine::Ollama {
1323                client,
1324                model,
1325                base_url,
1326            } => {
1327                let expected_text_count = texts.len();
1328                let endpoint = build_ollama_embeddings_endpoint(base_url);
1329
1330                #[derive(Serialize)]
1331                struct OllamaPayload<'a> {
1332                    model: &'a str,
1333                    input: Vec<String>,
1334                }
1335
1336                let payload = OllamaPayload {
1337                    model,
1338                    input: texts,
1339                };
1340
1341                let raw = send_embedding_request(
1342                    || {
1343                        // `.json(&payload)` sets Content-Type automatically.
1344                        // Same duplicate-header trap as the OpenAI branch above
1345                        // — most Ollama servers tolerate it, but the
1346                        // single-Content-Type form is the correct one.
1347                        client.post(&endpoint).json(&payload)
1348                    },
1349                    "ollama",
1350                    policy,
1351                )?;
1352
1353                #[derive(Deserialize)]
1354                struct OllamaResponse {
1355                    embeddings: Vec<Vec<f32>>,
1356                }
1357
1358                let parsed: OllamaResponse = serde_json::from_str(&raw)
1359                    .map_err(|error| format!("invalid ollama response: {error}"))?;
1360                if parsed.embeddings.is_empty() {
1361                    return Err("ollama response returned no embeddings".to_string());
1362                }
1363                if parsed.embeddings.len() != expected_text_count {
1364                    return Err(format!(
1365                        "ollama response returned {} embeddings for {} inputs",
1366                        parsed.embeddings.len(),
1367                        expected_text_count
1368                    ));
1369                }
1370
1371                let vectors = parsed.embeddings;
1372                for vector in &vectors {
1373                    if vector.is_empty() {
1374                        return Err("ollama response contained empty embeddings".to_string());
1375                    }
1376                }
1377
1378                self.dimension = vectors.first().map(Vec::len);
1379                Ok(vectors)
1380            }
1381            SemanticEmbeddingEngine::Synapse(client) => {
1382                let vectors = match policy {
1383                    EmbeddingRequestPolicy::Build => client
1384                        .embed_batch(&texts)
1385                        .map_err(|error| error.to_string())?,
1386                    EmbeddingRequestPolicy::Query(budget) => {
1387                        let timeout = Duration::from_millis(budget.timeout_ms);
1388                        texts
1389                            .iter()
1390                            .map(|text| client.embed_query(text, timeout))
1391                            .collect::<Result<Vec<_>, _>>()
1392                            .map_err(|error| error.to_string())?
1393                    }
1394                };
1395                self.dimension = vectors.first().map(Vec::len);
1396                Ok(vectors)
1397            }
1398        }
1399    }
1400}
1401
1402/// Platform library filename for the plugin-managed ONNX Runtime.
1403///
1404/// Mirrors `ORT_PLATFORM_MAP` in packages/aft-bridge/src/onnx-runtime.ts. A
1405/// layout change on either side must update both — the plugin downloads the
1406/// runtime into `<storage_dir>/onnxruntime/<version>/` and this resolver must
1407/// find it at the same path.
1408#[cfg(target_os = "linux")]
1409const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.so";
1410#[cfg(target_os = "macos")]
1411const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.dylib";
1412#[cfg(target_os = "windows")]
1413const MANAGED_ORT_LIB_NAME: &str = "onnxruntime.dll";
1414
1415/// Minimum managed ONNX Runtime minor version this resolver will accept.
1416///
1417/// Mirrors the `REQUIRED_ORT_MIN_MINOR` floor in onnx-runtime.ts and the 1.20
1418/// floor `pre_validate_onnx_runtime` enforces. A managed install below this
1419/// would be handed to ort and rejected there, so the resolver must skip it.
1420const MANAGED_ORT_MIN_MINOR: u32 = 20;
1421
1422/// Resolve the plugin-managed ONNX Runtime under the ACTIVE storage dir and
1423/// export it as `ORT_DYLIB_PATH` for the process.
1424///
1425/// The plugin (packages/aft-bridge/src/onnx-runtime.ts) downloads the runtime
1426/// to `<storage_dir>/onnxruntime/<version>/<libname>` and exports ORT_DYLIB_PATH
1427/// into the child env. A bare `aft` binary has no such step: without this
1428/// resolver, `pre_validate_onnx_runtime` dlopens the bare soname, which only
1429/// works with a system-installed runtime. This makes the standalone binary pick
1430/// up the runtime the plugin already downloaded.
1431///
1432/// Resolution order:
1433///   1. If `ORT_DYLIB_PATH` is already set (an explicit user override, or the
1434///      plugin already exported it), do nothing — the caller's choice wins and
1435///      the resolver must not run at all.
1436///   2. Enumerate `<storage_dir>/onnxruntime/` version directories, keep only
1437///      parseable `1.x.y` with x >= 20, pick the highest, and if its library
1438///      file exists set `ORT_DYLIB_PATH` to it.
1439///   3. Otherwise leave the env untouched; `pre_validate_onnx_runtime` falls
1440///      back to the bare soname + doctor hint as before.
1441///
1442/// # Process-global env mutation
1443/// This sets a process-wide env var and must run ONCE at startup, before any
1444/// worker threads spawn (the warmup CLI main and the standalone main's semantic
1445/// init path). Setting it lazily from a worker thread would race ort's own
1446/// dlopen and other threads reading the env. The function is idempotent: once
1447/// `ORT_DYLIB_PATH` is set, subsequent calls short-circuit.
1448pub fn resolve_managed_onnx_runtime(storage_dir: &Path) {
1449    if std::env::var_os("ORT_DYLIB_PATH").is_some() {
1450        return;
1451    }
1452    let Some(lib_path) = find_managed_onnx_runtime(storage_dir) else {
1453        return;
1454    };
1455    std::env::set_var("ORT_DYLIB_PATH", &lib_path);
1456    slog_info!(
1457        "using plugin-managed ONNX Runtime at {}",
1458        lib_path.display()
1459    );
1460}
1461
1462/// Find the highest compatible managed ONNX Runtime library under
1463/// `<storage_dir>/onnxruntime/`, or None when absent/incompatible.
1464///
1465/// Mirrors the plugin's `resolveCachedOnnxRuntimeDir`: the library may live at
1466/// the version root (the plugin's own flattened install) or under a `lib/`
1467/// subdir (manual Microsoft-archive installs, issue #71).
1468fn find_managed_onnx_runtime(storage_dir: &Path) -> Option<PathBuf> {
1469    let base = storage_dir.join("onnxruntime");
1470    let entries = std::fs::read_dir(&base).ok()?;
1471    #[cfg(test)]
1472    {
1473        // Test-only probe: counts how many times the resolver actually reads
1474        // the storage tree. Lets a negative-control test assert that a pre-set
1475        // ORT_DYLIB_PATH short-circuits the resolver without touching the tree.
1476        MANAGED_ORT_PROBE_READS.fetch_add(1, Ordering::Relaxed);
1477    }
1478    let mut best: Option<(u32, u32, PathBuf)> = None;
1479    for entry in entries.flatten() {
1480        let path = entry.path();
1481        if !path.is_dir() {
1482            continue;
1483        }
1484        let Some((major, minor)) = parse_managed_ort_version(&entry.file_name().to_string_lossy())
1485        else {
1486            continue;
1487        };
1488        if major != 1 || minor < MANAGED_ORT_MIN_MINOR {
1489            continue;
1490        }
1491        let Some(lib_path) = managed_ort_lib_in_version_dir(&path) else {
1492            continue;
1493        };
1494        if best
1495            .as_ref()
1496            .is_none_or(|(best_major, best_minor, _)| (major, minor) > (*best_major, *best_minor))
1497        {
1498            best = Some((major, minor, lib_path));
1499        }
1500    }
1501    best.map(|(_, _, path)| path)
1502}
1503
1504/// Locate the library file inside one `<version>` directory, preferring the
1505/// version root over a `lib/` subdir (mirrors `resolveCachedOnnxRuntimeDir`).
1506fn managed_ort_lib_in_version_dir(version_dir: &Path) -> Option<PathBuf> {
1507    let root = version_dir.join(MANAGED_ORT_LIB_NAME);
1508    if root.is_file() {
1509        return Some(root);
1510    }
1511    let lib_subdir = version_dir.join("lib").join(MANAGED_ORT_LIB_NAME);
1512    if lib_subdir.is_file() {
1513        return Some(lib_subdir);
1514    }
1515    None
1516}
1517
1518/// Parse a `major.minor.patch` triple from a version directory name. Returns
1519/// None for anything that is not exactly a three-part numeric version (so
1520/// non-version dirs and malformed names are ignored).
1521fn parse_managed_ort_version(name: &str) -> Option<(u32, u32)> {
1522    let mut parts = name.split('.');
1523    let major = parts.next()?.parse::<u32>().ok()?;
1524    let minor = parts.next()?.parse::<u32>().ok()?;
1525    let _patch = parts.next()?.parse::<u32>().ok()?;
1526    // Reject trailing junk like "1.24.4.tmp" or "1.24.4.5".
1527    if parts.next().is_some() {
1528        return None;
1529    }
1530    Some((major, minor))
1531}
1532
1533/// Pre-validate ONNX Runtime by attempting a raw dlopen before ort touches it.
1534/// This catches broken/incompatible .so files without risking a panic in the ort crate.
1535/// Also checks the runtime version via OrtGetApiBase if available.
1536pub fn pre_validate_onnx_runtime() -> Result<(), String> {
1537    let dylib_path = std::env::var("ORT_DYLIB_PATH").ok();
1538
1539    #[cfg(any(target_os = "linux", target_os = "macos"))]
1540    {
1541        #[cfg(target_os = "linux")]
1542        let default_name = "libonnxruntime.so";
1543        #[cfg(target_os = "macos")]
1544        let default_name = "libonnxruntime.dylib";
1545
1546        let lib_name = dylib_path.as_deref().unwrap_or(default_name);
1547
1548        unsafe {
1549            let c_name = std::ffi::CString::new(lib_name)
1550                .map_err(|e| format!("invalid library path: {}", e))?;
1551            let handle = libc::dlopen(c_name.as_ptr(), libc::RTLD_NOW);
1552            if handle.is_null() {
1553                let err = libc::dlerror();
1554                let msg = if err.is_null() {
1555                    "unknown dlopen error".to_string()
1556                } else {
1557                    std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned()
1558                };
1559                return Err(format!(
1560                    "ONNX Runtime not found. dlopen('{}') failed: {}. \
1561                     Run `npx @cortexkit/aft doctor` to diagnose.",
1562                    lib_name, msg
1563                ));
1564            }
1565
1566            // Try to detect the runtime version from the actual loaded library
1567            // path first. A bare dlopen("libonnxruntime.so") may resolve to an
1568            // older system ORT through loader search paths; checking only the
1569            // caller-supplied soname would miss that and let ort fail opaquely.
1570            let (detected_version, version_source) =
1571                detect_ort_version_from_loaded_library(handle, lib_name);
1572
1573            libc::dlclose(handle);
1574
1575            // Check version compatibility — we need 1.20+.
1576            if let Some(ref version) = detected_version {
1577                let parts: Vec<&str> = version.split('.').collect();
1578                if let (Some(major), Some(minor)) = (
1579                    parts.first().and_then(|s| s.parse::<u32>().ok()),
1580                    parts.get(1).and_then(|s| s.parse::<u32>().ok()),
1581                ) {
1582                    if major != 1 || minor < 20 {
1583                        return Err(format_ort_version_mismatch(version, &version_source));
1584                    }
1585                }
1586            }
1587        }
1588    }
1589
1590    #[cfg(target_os = "windows")]
1591    {
1592        // Validate ONNX Runtime availability on Windows by loading the DLL
1593        // via LoadLibraryExW before the ort crate attempts its own LoadLibrary.
1594        // This way we can produce a friendly error (with installation hints)
1595        // instead of a raw LoadLibrary failure from deep inside fastembed.
1596        let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
1597
1598        // Use kernel32 LoadLibraryExW for the validation — built-in, no
1599        // crate dependency required. GetModuleFileNameW resolves the loaded
1600        // DLL path for version probing via the version.dll API.
1601        #[link(name = "kernel32")]
1602        extern "system" {
1603            fn LoadLibraryExW(
1604                lpLibFileName: *const u16,
1605                hFile: *mut std::ffi::c_void,
1606                dwFlags: u32,
1607            ) -> *mut std::ffi::c_void;
1608            fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
1609            fn GetModuleFileNameW(
1610                hModule: *mut std::ffi::c_void,
1611                lpFilename: *mut u16,
1612                nSize: u32,
1613            ) -> u32;
1614        }
1615
1616        #[link(name = "version")]
1617        extern "system" {
1618            fn GetFileVersionInfoSizeW(lptstrFilename: *const u16, lpdwHandle: *mut u32) -> u32;
1619            fn GetFileVersionInfoW(
1620                lptstrFilename: *const u16,
1621                dwHandle: u32,
1622                dwLen: u32,
1623                lpData: *mut std::ffi::c_void,
1624            ) -> i32;
1625            fn VerQueryValueW(
1626                pBlock: *mut std::ffi::c_void,
1627                lpSubBlock: *const u16,
1628                lplpBuffer: *mut *mut std::ffi::c_void,
1629                puLen: *mut u32,
1630            ) -> i32;
1631        }
1632
1633        #[repr(C)]
1634        struct VS_FIXEDFILEINFO {
1635            dw_signature: u32,
1636            dw_struc_version: u32,
1637            dw_file_version_ms: u32, // HIWORD major, LOWORD minor
1638            dw_file_version_ls: u32, // HIWORD build, LOWORD revision
1639            dw_product_version_ms: u32,
1640            dw_product_version_ls: u32,
1641            dw_file_flags_mask: u32,
1642            dw_file_flags: u32,
1643            dw_file_os: u32,
1644            dw_file_type: u32,
1645            dw_file_subtype: u32,
1646            dw_file_date_ms: u32,
1647            dw_file_date_ls: u32,
1648        }
1649
1650        unsafe {
1651            use std::os::windows::ffi::OsStrExt;
1652            let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
1653                .encode_wide()
1654                .chain(std::iter::once(0))
1655                .collect();
1656
1657            let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
1658            if handle.is_null() {
1659                let err = std::io::Error::last_os_error();
1660                return Err(format!(
1661                    "ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
1662                     Run `npx @cortexkit/aft doctor` to diagnose.",
1663                    lib_name, err
1664                ));
1665            }
1666
1667            // Probe the file version from PE resources so we can reject
1668            // outdated DLLs (e.g. v1.9.x) before the ort crate panics.
1669            let mut detected_major: u32 = 0;
1670            let mut detected_minor: u32 = 0;
1671            // Use MAX_UNICODEPATH (32767) so deeply nested ORT paths (e.g.
1672            // long NuGet package paths under %USERPROFILE%) never truncate.
1673            // GetModuleFileNameW truncates silently when the buffer is too
1674            // small, which causes version probing to fail and the version
1675            // check to be bypassed — better to allocate generously.
1676            let mut path_buf = [0u16; 32767];
1677            let path_len = GetModuleFileNameW(handle, path_buf.as_mut_ptr(), 32767);
1678            if path_len > 0 {
1679                let mut dummy_handle: u32 = 0;
1680                let info_size = GetFileVersionInfoSizeW(path_buf.as_ptr(), &mut dummy_handle);
1681                if info_size > 0 {
1682                    let mut info = vec![0u8; info_size as usize];
1683                    if GetFileVersionInfoW(
1684                        path_buf.as_ptr(),
1685                        0,
1686                        info_size,
1687                        info.as_mut_ptr() as *mut std::ffi::c_void,
1688                    ) != 0
1689                    {
1690                        let sub_block = "\\\0".encode_utf16().collect::<Vec<u16>>();
1691                        let mut vs_info: *mut std::ffi::c_void = std::ptr::null_mut();
1692                        let mut vs_len: u32 = 0;
1693                        if VerQueryValueW(
1694                            info.as_mut_ptr() as *mut std::ffi::c_void,
1695                            sub_block.as_ptr(),
1696                            &mut vs_info,
1697                            &mut vs_len,
1698                        ) != 0
1699                            && !vs_info.is_null()
1700                        {
1701                            let fixed = vs_info as *const VS_FIXEDFILEINFO;
1702                            detected_major = (*fixed).dw_file_version_ms >> 16;
1703                            detected_minor = (*fixed).dw_file_version_ms & 0xFFFF;
1704                        }
1705                    }
1706                }
1707            }
1708
1709            FreeLibrary(handle);
1710
1711            // Version compatibility check (mirrors the Linux/macOS path).
1712            // If version could not be detected (detected_major == 0) we let
1713            // the load succeed — the ort crate will diagnose further.
1714            if detected_major != 0 && (detected_major != 1 || detected_minor < 20) {
1715                let ver = format!("{}.{}", detected_major, detected_minor);
1716                return Err(format_ort_version_mismatch(&ver, lib_name));
1717            }
1718        }
1719    }
1720
1721    Ok(())
1722}
1723
1724#[cfg(any(target_os = "linux", target_os = "macos"))]
1725unsafe fn loaded_library_path_from_handle(handle: *mut std::ffi::c_void) -> Option<String> {
1726    let symbol_name = std::ffi::CString::new("OrtGetApiBase").ok()?;
1727    let symbol = unsafe { libc::dlsym(handle, symbol_name.as_ptr()) };
1728    if symbol.is_null() {
1729        return None;
1730    }
1731
1732    let mut info = std::mem::MaybeUninit::<libc::Dl_info>::uninit();
1733    if unsafe { libc::dladdr(symbol, info.as_mut_ptr()) } == 0 {
1734        return None;
1735    }
1736
1737    let info = unsafe { info.assume_init() };
1738    if info.dli_fname.is_null() {
1739        return None;
1740    }
1741
1742    Some(
1743        unsafe { std::ffi::CStr::from_ptr(info.dli_fname) }
1744            .to_string_lossy()
1745            .into_owned(),
1746    )
1747}
1748
1749#[cfg(any(target_os = "linux", target_os = "macos"))]
1750fn detect_ort_version_from_resolved_or_requested(
1751    resolved_path: Option<String>,
1752    requested_lib_name: &str,
1753) -> (Option<String>, String) {
1754    if let Some(path) = resolved_path {
1755        if let Some(version) = detect_ort_version_from_path(&path) {
1756            return (Some(version), path);
1757        }
1758        return (detect_ort_version_from_path(requested_lib_name), path);
1759    }
1760
1761    (
1762        detect_ort_version_from_path(requested_lib_name),
1763        requested_lib_name.to_string(),
1764    )
1765}
1766
1767#[cfg(any(target_os = "linux", target_os = "macos"))]
1768fn detect_ort_version_from_loaded_library(
1769    handle: *mut std::ffi::c_void,
1770    requested_lib_name: &str,
1771) -> (Option<String>, String) {
1772    detect_ort_version_from_resolved_or_requested(
1773        unsafe { loaded_library_path_from_handle(handle) },
1774        requested_lib_name,
1775    )
1776}
1777
1778/// Try to extract the ORT version from the library filename or resolved symlink.
1779/// Examples: "libonnxruntime.so.1.19.0" → "1.19.0", "libonnxruntime.1.24.4.dylib" → "1.24.4"
1780#[cfg(any(target_os = "linux", target_os = "macos"))]
1781fn detect_ort_version_from_path(lib_path: &str) -> Option<String> {
1782    let path = std::path::Path::new(lib_path);
1783
1784    // Try the path as given, then follow symlinks
1785    for candidate in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()]
1786        .into_iter()
1787        .flatten()
1788    {
1789        if let Some(name) = candidate.file_name().and_then(|n| n.to_str()) {
1790            if let Some(version) = extract_version_from_filename(name) {
1791                return Some(version);
1792            }
1793        }
1794    }
1795
1796    // Also check for versioned siblings in the same directory
1797    if let Some(parent) = path.parent() {
1798        if let Ok(entries) = std::fs::read_dir(parent) {
1799            for entry in entries.flatten() {
1800                if let Some(name) = entry.file_name().to_str() {
1801                    if name.starts_with("libonnxruntime") {
1802                        if let Some(version) = extract_version_from_filename(name) {
1803                            return Some(version);
1804                        }
1805                    }
1806                }
1807            }
1808        }
1809    }
1810
1811    None
1812}
1813
1814/// Extract version from filenames like "libonnxruntime.so.1.19.0" or "libonnxruntime.1.24.4.dylib"
1815#[cfg(any(target_os = "linux", target_os = "macos"))]
1816fn extract_version_from_filename(name: &str) -> Option<String> {
1817    // Match patterns: .so.X.Y.Z or .X.Y.Z.dylib or .X.Y.Z.so
1818    let re = regex::Regex::new(r"(\d+\.\d+\.\d+)").ok()?;
1819    re.find(name).map(|m| m.as_str().to_string())
1820}
1821
1822fn suggest_removal_command(lib_path: &str) -> String {
1823    if lib_path.starts_with("/usr/local/lib")
1824        || lib_path == "libonnxruntime.so"
1825        || lib_path == "libonnxruntime.dylib"
1826    {
1827        #[cfg(target_os = "linux")]
1828        return "   sudo rm /usr/local/lib/libonnxruntime* && sudo ldconfig".to_string();
1829        #[cfg(target_os = "macos")]
1830        return "   sudo rm /usr/local/lib/libonnxruntime*".to_string();
1831    }
1832    format!("   rm '{}'", lib_path)
1833}
1834
1835/// Build the user-facing error message for an incompatible ONNX Runtime
1836/// install. Extracted as a pure helper so we can unit-test the wording
1837/// stability — the auto-fix recommendation must always come first because
1838/// it's the only safe option, and the system-rm step must remain present
1839/// because some users prefer the system-wide cleanup path.
1840pub(crate) fn format_ort_version_mismatch(version: &str, lib_name: &str) -> String {
1841    format!(
1842        "ONNX Runtime version mismatch: found v{} at '{}', but AFT requires v1.20+. \
1843         Solutions:\n\
1844         1. Auto-fix (recommended): run `npx @cortexkit/aft doctor --fix`. \
1845         This downloads AFT-managed ONNX Runtime v1.24 into AFT's storage and \
1846         configures the bridge to load it instead of the system library — no \
1847         changes to '{}'.\n\
1848         2. Remove the old library and restart (AFT auto-downloads the correct version on next start):\n\
1849         {}\n\
1850         3. Or install ONNX Runtime 1.24 system-wide: https://github.com/microsoft/onnxruntime/releases/tag/v1.24.0\n\
1851         4. Run `npx @cortexkit/aft doctor` for full diagnostics.",
1852        version,
1853        lib_name,
1854        lib_name,
1855        suggest_removal_command(lib_name),
1856    )
1857}
1858
1859pub fn is_onnx_runtime_unavailable(message: &str) -> bool {
1860    if message.trim_start().starts_with("ONNX Runtime not found.") {
1861        return true;
1862    }
1863
1864    let message = message.to_ascii_lowercase();
1865    let mentions_onnx_runtime = ["onnx runtime", "onnxruntime", "libonnxruntime"]
1866        .iter()
1867        .any(|pattern| message.contains(pattern));
1868    let mentions_dynamic_load_failure = [
1869        "shared library",
1870        "dynamic library",
1871        "failed to load",
1872        "could not load",
1873        "unable to load",
1874        "dlopen",
1875        "loadlibrary",
1876        "no such file",
1877        "not found",
1878    ]
1879    .iter()
1880    .any(|pattern| message.contains(pattern));
1881
1882    mentions_onnx_runtime && mentions_dynamic_load_failure
1883}
1884
1885pub fn format_embedding_init_error(error: impl Display) -> String {
1886    let message = error.to_string();
1887
1888    if is_onnx_runtime_unavailable(&message) {
1889        return format!("{ONNX_RUNTIME_INSTALL_HINT} Original error: {message}");
1890    }
1891
1892    format!("failed to initialize semantic embedding model: {message}")
1893}
1894
1895/// A chunk of code ready for embedding — derived from a Symbol with context enrichment
1896#[derive(Debug, Clone)]
1897pub struct SemanticChunk {
1898    /// Absolute file path
1899    pub file: PathBuf,
1900    /// Symbol name
1901    pub name: String,
1902    /// Fully-qualified symbol name, when known from the outline scope chain.
1903    pub qualified_name: Option<String>,
1904    /// Symbol kind (function, class, struct, etc.)
1905    pub kind: SymbolKind,
1906    /// Line range (0-based internally, inclusive)
1907    pub start_line: u32,
1908    pub end_line: u32,
1909    /// Whether the symbol is exported
1910    pub exported: bool,
1911    /// The enriched text that gets embedded (name + file + kind + signature + body snippet)
1912    pub embed_text: String,
1913    /// Short code snippet for display in results
1914    pub snippet: String,
1915}
1916
1917/// A stored embedding entry — chunk metadata + vector
1918#[derive(Debug, Clone)]
1919pub struct EmbeddingEntry {
1920    chunk: SemanticChunk,
1921    vector: Vec<f32>,
1922    /// Cached L2 norm so searches only recompute the query norm. Remote embedding
1923    /// backends do not guarantee unit vectors, so keep the actual norm instead of
1924    /// assuming it is 1.0.
1925    norm: f32,
1926}
1927
1928impl EmbeddingEntry {
1929    fn new(chunk: SemanticChunk, vector: Vec<f32>) -> Self {
1930        let norm = vector_norm(&vector);
1931        Self {
1932            chunk,
1933            vector,
1934            norm,
1935        }
1936    }
1937}
1938
1939#[derive(Debug)]
1940struct SharedSemanticBase {
1941    entries: Vec<EmbeddingEntry>,
1942    file_mtimes: HashMap<PathBuf, SystemTime>,
1943    file_sizes: HashMap<PathBuf, u64>,
1944    any_missing_sizes: bool,
1945    file_hashes: HashMap<PathBuf, blake3::Hash>,
1946    dimension: usize,
1947    fingerprint: Option<SemanticIndexFingerprint>,
1948    deferred_files: HashSet<PathBuf>,
1949}
1950
1951#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1952struct SharedSemanticBaseKey {
1953    artifact_cache_key: String,
1954    fingerprint: String,
1955    artifact_content_hash: blake3::Hash,
1956}
1957
1958type SharedSemanticBaseRegistry = HashMap<SharedSemanticBaseKey, Weak<SharedSemanticBase>>;
1959
1960fn shared_semantic_bases() -> &'static Mutex<SharedSemanticBaseRegistry> {
1961    static REGISTRY: OnceLock<Mutex<SharedSemanticBaseRegistry>> = OnceLock::new();
1962    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
1963}
1964
1965static SHARED_SEMANTIC_BASE_LOADS: AtomicUsize = AtomicUsize::new(0);
1966static SHARED_SEMANTIC_BASE_HITS: AtomicUsize = AtomicUsize::new(0);
1967
1968impl SharedSemanticBase {
1969    fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
1970        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1971            bytes.saturating_add(
1972                crate::memory::usize_to_u64(entry.vector.len())
1973                    .saturating_mul(std::mem::size_of::<f32>() as u64),
1974            )
1975        });
1976        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1977            bytes
1978                .saturating_add(crate::memory::path_bytes(&entry.chunk.file))
1979                .saturating_add(crate::memory::usize_to_u64(entry.chunk.name.len()))
1980                .saturating_add(
1981                    entry
1982                        .chunk
1983                        .qualified_name
1984                        .as_ref()
1985                        .map(|name| crate::memory::usize_to_u64(name.len()))
1986                        .unwrap_or(0),
1987                )
1988                .saturating_add(crate::memory::usize_to_u64(entry.chunk.embed_text.len()))
1989                .saturating_add(crate::memory::usize_to_u64(entry.chunk.snippet.len()))
1990        });
1991        let metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
1992            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64)
1993            .saturating_add(
1994                self.file_mtimes
1995                    .keys()
1996                    .chain(self.file_sizes.keys())
1997                    .chain(self.file_hashes.keys())
1998                    .chain(self.deferred_files.iter())
1999                    .map(|path| crate::memory::path_bytes(path))
2000                    .fold(0u64, u64::saturating_add),
2001            )
2002            .saturating_add(
2003                crate::memory::usize_to_u64(self.file_mtimes.len())
2004                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
2005            )
2006            .saturating_add(
2007                crate::memory::usize_to_u64(self.file_sizes.len())
2008                    .saturating_mul(std::mem::size_of::<u64>() as u64),
2009            )
2010            .saturating_add(
2011                crate::memory::usize_to_u64(self.file_hashes.len())
2012                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
2013            );
2014        crate::memory::MemoryEstimate::estimated(
2015            vector_bytes
2016                .saturating_add(text_bytes)
2017                .saturating_add(metadata_bytes),
2018        )
2019        .count("entries", self.entries.len())
2020        .count("indexed_files", self.file_mtimes.len())
2021        .count_u64("vector_bytes", vector_bytes)
2022        .count_u64("text_bytes", text_bytes)
2023        .count_u64("metadata_bytes", metadata_bytes)
2024    }
2025}
2026
2027pub(crate) fn shared_semantic_bases_memory() -> crate::memory::MemoryEstimate {
2028    let mut registry = shared_semantic_bases()
2029        .lock()
2030        .unwrap_or_else(std::sync::PoisonError::into_inner);
2031    registry.retain(|_, base| base.strong_count() > 0);
2032    let bases = registry
2033        .values()
2034        .filter_map(Weak::upgrade)
2035        .collect::<Vec<_>>();
2036    let estimates = bases
2037        .iter()
2038        .map(|base| base.estimated_memory())
2039        .collect::<Vec<_>>();
2040    let bytes = estimates.iter().fold(0u64, |sum, estimate| {
2041        sum.saturating_add(estimate.estimated_bytes.unwrap_or(0))
2042    });
2043    let count_bytes = |name: &str| {
2044        estimates.iter().fold(0u64, |sum, estimate| {
2045            sum.saturating_add(estimate.counts.get(name).copied().unwrap_or(0))
2046        })
2047    };
2048    crate::memory::MemoryEstimate::estimated(bytes)
2049        .count("bases", bases.len())
2050        .count("entries", bases.iter().map(|base| base.entries.len()).sum())
2051        .count_u64("vector_bytes", count_bytes("vector_bytes"))
2052        .count_u64("text_bytes", count_bytes("text_bytes"))
2053        .count_u64("metadata_bytes", count_bytes("metadata_bytes"))
2054        .count_u64(
2055            "loads",
2056            SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) as u64,
2057        )
2058        .count_u64(
2059            "hits",
2060            SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) as u64,
2061        )
2062}
2063
2064fn borrowed_artifact_identity(data_path: &Path) -> Result<(String, blake3::Hash), String> {
2065    let mut file = fs::File::open(data_path).map_err(|error| error.to_string())?;
2066    let mut hasher = blake3::Hasher::new();
2067    hasher
2068        .update_reader(&mut file)
2069        .map_err(|error| error.to_string())?;
2070    let artifact_content_hash = hasher.finalize();
2071
2072    let mut header = BufReader::new(fs::File::open(data_path).map_err(|error| error.to_string())?);
2073    let mut fixed = [0u8; HEADER_BYTES_V2];
2074    header
2075        .read_exact(&mut fixed)
2076        .map_err(|error| error.to_string())?;
2077    if fixed[0] != SEMANTIC_INDEX_VERSION_V6 && fixed[0] != SEMANTIC_INDEX_VERSION_V7 {
2078        return Err(format!(
2079            "unsupported semantic artifact version {}",
2080            fixed[0]
2081        ));
2082    }
2083    let fingerprint_len = u32::from_le_bytes(fixed[9..13].try_into().unwrap()) as usize;
2084    if fingerprint_len == 0 || fingerprint_len > 64 * 1024 {
2085        return Err("semantic artifact fingerprint is missing or oversized".to_string());
2086    }
2087    let mut fingerprint = vec![0u8; fingerprint_len];
2088    header
2089        .read_exact(&mut fingerprint)
2090        .map_err(|error| error.to_string())?;
2091    let fingerprint = String::from_utf8(fingerprint).map_err(|error| error.to_string())?;
2092    Ok((fingerprint, artifact_content_hash))
2093}
2094
2095/// The semantic index — stores embeddings for all symbols in a project.
2096/// Borrow-only roots retain only a root path plus an Arc to immutable relative data.
2097#[derive(Debug, Clone)]
2098pub struct SemanticIndex {
2099    entries: Vec<EmbeddingEntry>,
2100    /// Track which files are indexed and their mtime for staleness detection
2101    file_mtimes: HashMap<PathBuf, SystemTime>,
2102    /// Track indexed file sizes alongside mtimes for staleness detection
2103    file_sizes: HashMap<PathBuf, u64>,
2104    /// Avoid walking every indexed path on warm refreshes once size metadata is complete.
2105    any_missing_sizes: bool,
2106    file_hashes: HashMap<PathBuf, blake3::Hash>,
2107    /// Embedding dimension (384 for MiniLM-L6-v2)
2108    dimension: usize,
2109    fingerprint: Option<SemanticIndexFingerprint>,
2110    project_root: PathBuf,
2111    deferred_files: HashSet<PathBuf>,
2112    shared_base: Option<Arc<SharedSemanticBase>>,
2113    #[cfg(test)]
2114    removal_retain_passes: usize,
2115}
2116
2117#[derive(Debug, Clone, Copy)]
2118struct IndexedFileMetadata {
2119    mtime: SystemTime,
2120    size: u64,
2121    content_hash: blake3::Hash,
2122}
2123
2124#[derive(Debug, Default, Clone, Copy)]
2125struct SemanticCollectPhaseTimings {
2126    sched: Duration,
2127    read_hash: Duration,
2128    parse: Duration,
2129    extract: Duration,
2130    build: Duration,
2131}
2132
2133impl SemanticCollectPhaseTimings {
2134    fn add_assign(&mut self, other: Self) {
2135        self.sched += other.sched;
2136        self.read_hash += other.read_hash;
2137        self.parse += other.parse;
2138        self.extract += other.extract;
2139        self.build += other.build;
2140    }
2141}
2142
2143type CollectedSemanticFile = (
2144    PathBuf,
2145    Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
2146    SemanticCollectPhaseTimings,
2147);
2148
2149/// Result of an incremental refresh of the semantic index. Counts are file
2150/// counts; `total_processed` is the number of current/deleted files considered.
2151#[derive(Debug, Default, Clone, Copy)]
2152pub struct RefreshSummary {
2153    pub changed: usize,
2154    pub added: usize,
2155    pub deleted: usize,
2156    pub total_processed: usize,
2157}
2158
2159impl RefreshSummary {
2160    /// True when no files were touched.
2161    pub fn is_noop(&self) -> bool {
2162        self.changed == 0 && self.added == 0 && self.deleted == 0
2163    }
2164}
2165
2166#[derive(Debug, Default)]
2167pub struct InvalidatedFilesRefresh {
2168    /// Full replacement entries for `completed_paths`, not just newly embedded
2169    /// chunks. `apply_refresh_update` removes completed paths before extending
2170    /// this set, so reused chunks must travel in this delta too.
2171    pub added_entries: Vec<EmbeddingEntry>,
2172    pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
2173    pub completed_paths: Vec<PathBuf>,
2174    pub summary: RefreshSummary,
2175}
2176
2177#[derive(Debug, Clone)]
2178struct ReusableEmbedding {
2179    embed_text: String,
2180    vector: Vec<f32>,
2181}
2182
2183type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
2184
2185/// Search result from a semantic query
2186#[derive(Debug, Clone)]
2187pub struct SemanticResult {
2188    pub file: PathBuf,
2189    pub name: String,
2190    pub qualified_name: Option<String>,
2191    pub kind: SymbolKind,
2192    pub start_line: u32,
2193    pub end_line: u32,
2194    pub exported: bool,
2195    pub snippet: String,
2196    pub score: f32,
2197    pub rank_score: f32,
2198    pub cap_protected: bool,
2199    pub source: &'static str,
2200}
2201
2202fn relativize_semantic_map<T>(
2203    project_root: &Path,
2204    map: HashMap<PathBuf, T>,
2205) -> Option<HashMap<PathBuf, T>> {
2206    map.into_iter()
2207        .map(|(path, value)| cache_relative_path(project_root, &path).map(|path| (path, value)))
2208        .collect()
2209}
2210
2211impl SemanticIndex {
2212    fn from_shared_base(project_root: PathBuf, shared_base: Arc<SharedSemanticBase>) -> Self {
2213        debug_assert!(project_root.is_absolute());
2214        Self {
2215            entries: Vec::new(),
2216            file_mtimes: HashMap::new(),
2217            file_sizes: HashMap::new(),
2218            any_missing_sizes: false,
2219            file_hashes: HashMap::new(),
2220            dimension: shared_base.dimension,
2221            fingerprint: shared_base.fingerprint.clone(),
2222            project_root,
2223            deferred_files: HashSet::new(),
2224            shared_base: Some(shared_base),
2225            #[cfg(test)]
2226            removal_retain_passes: 0,
2227        }
2228    }
2229
2230    pub(crate) fn adopt_frozen_base_for_root(
2231        &mut self,
2232        project_root: &Path,
2233        config: &SemanticBackendConfig,
2234    ) -> Option<Self> {
2235        let expected = SemanticIndexFingerprint::for_config_dimension(config, self.dimension());
2236        if !self
2237            .fingerprint()
2238            .is_some_and(|fingerprint| fingerprint.matches(&expected))
2239        {
2240            return None;
2241        }
2242
2243        if let Some(base) = self.shared_base.as_ref() {
2244            return Some(Self::from_shared_base(
2245                project_root.to_path_buf(),
2246                Arc::clone(base),
2247            ));
2248        }
2249
2250        let paths_are_shareable = self
2251            .entries
2252            .iter()
2253            .all(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
2254            && self
2255                .file_mtimes
2256                .keys()
2257                .chain(self.file_sizes.keys())
2258                .chain(self.file_hashes.keys())
2259                .chain(self.deferred_files.iter())
2260                .all(|path| cache_relative_path(&self.project_root, path).is_some());
2261        if !paths_are_shareable {
2262            return None;
2263        }
2264
2265        // Move the resident vectors into one immutable relative-path base rather
2266        // than cloning them. The owner and each matching worktree then retain
2267        // only an Arc plus their own root for path projection.
2268        let owner_root = self.project_root.clone();
2269        let placeholder = Self::new(owner_root.clone(), self.dimension());
2270        let private = std::mem::replace(self, placeholder);
2271        let base = Arc::new(
2272            private
2273                .into_shared_base()
2274                .expect("shareable semantic paths were validated before freezing"),
2275        );
2276        *self = Self::from_shared_base(owner_root, Arc::clone(&base));
2277        Some(Self::from_shared_base(project_root.to_path_buf(), base))
2278    }
2279
2280    fn into_shared_base(mut self) -> Option<SharedSemanticBase> {
2281        for entry in &mut self.entries {
2282            entry.chunk.file = cache_relative_path(&self.project_root, &entry.chunk.file)?;
2283        }
2284        let deferred_files = self
2285            .deferred_files
2286            .into_iter()
2287            .map(|path| cache_relative_path(&self.project_root, &path))
2288            .collect::<Option<HashSet<_>>>()?;
2289        Some(SharedSemanticBase {
2290            entries: self.entries,
2291            file_mtimes: relativize_semantic_map(&self.project_root, self.file_mtimes)?,
2292            file_sizes: relativize_semantic_map(&self.project_root, self.file_sizes)?,
2293            any_missing_sizes: self.any_missing_sizes,
2294            file_hashes: relativize_semantic_map(&self.project_root, self.file_hashes)?,
2295            dimension: self.dimension,
2296            fingerprint: self.fingerprint,
2297            deferred_files,
2298        })
2299    }
2300
2301    fn materialize_shared_base(&mut self) {
2302        let Some(base) = self.shared_base.take() else {
2303            return;
2304        };
2305        self.entries = base
2306            .entries
2307            .iter()
2308            .cloned()
2309            .map(|mut entry| {
2310                entry.chunk.file = self.project_root.join(&entry.chunk.file);
2311                entry
2312            })
2313            .collect();
2314        self.file_mtimes = base
2315            .file_mtimes
2316            .iter()
2317            .map(|(path, value)| (self.project_root.join(path), *value))
2318            .collect();
2319        self.file_sizes = base
2320            .file_sizes
2321            .iter()
2322            .map(|(path, value)| (self.project_root.join(path), *value))
2323            .collect();
2324        self.any_missing_sizes = base.any_missing_sizes;
2325        self.file_hashes = base
2326            .file_hashes
2327            .iter()
2328            .map(|(path, value)| (self.project_root.join(path), *value))
2329            .collect();
2330        self.dimension = base.dimension;
2331        self.fingerprint = base.fingerprint.clone();
2332        self.deferred_files = base
2333            .deferred_files
2334            .iter()
2335            .map(|path| self.project_root.join(path))
2336            .collect();
2337    }
2338
2339    pub fn new(project_root: PathBuf, dimension: usize) -> Self {
2340        debug_assert!(project_root.is_absolute());
2341        Self {
2342            entries: Vec::new(),
2343            file_mtimes: HashMap::new(),
2344            file_sizes: HashMap::new(),
2345            any_missing_sizes: false,
2346            file_hashes: HashMap::new(),
2347            dimension,
2348            fingerprint: None,
2349            project_root,
2350            deferred_files: HashSet::new(),
2351            shared_base: None,
2352            #[cfg(test)]
2353            removal_retain_passes: 0,
2354        }
2355    }
2356
2357    /// Number of embedded symbol entries.
2358    pub fn entry_count(&self) -> usize {
2359        self.shared_base
2360            .as_ref()
2361            .map(|base| base.entries.len())
2362            .unwrap_or_else(|| self.entries.len())
2363    }
2364
2365    /// Estimate resident semantic-index bytes from the vectors and metadata
2366    /// actually held by each entry. This intentionally excludes allocator and
2367    /// hash-table bucket overhead, which are not cheaply observable.
2368    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2369        if let Some(base) = &self.shared_base {
2370            return crate::memory::MemoryEstimate::estimated(0)
2371                .count("entries", base.entries.len())
2372                .count("dimensions", base.dimension)
2373                .count("indexed_files", base.file_mtimes.len())
2374                .count("shared_base_entries", base.entries.len())
2375                .count("overlay_entries", 0)
2376                .count_u64("vector_bytes", 0)
2377                .count_u64("text_bytes", 0)
2378                .count_u64("metadata_bytes", 0);
2379        }
2380        if self.entries.is_empty()
2381            && self.file_mtimes.is_empty()
2382            && self.file_sizes.is_empty()
2383            && self.file_hashes.is_empty()
2384            && self.deferred_files.is_empty()
2385        {
2386            return crate::memory::MemoryEstimate::estimated(0)
2387                .count("entries", 0)
2388                .count("dimensions", self.dimension)
2389                .count("indexed_files", 0)
2390                .count_u64("vector_bytes", 0)
2391                .count_u64("text_bytes", 0)
2392                .count_u64("metadata_bytes", 0)
2393                .count_u64("average_text_bytes", 0)
2394                .count_u64("average_metadata_bytes", 0);
2395        }
2396        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
2397            bytes.saturating_add(
2398                crate::memory::usize_to_u64(entry.vector.len())
2399                    .saturating_mul(std::mem::size_of::<f32>() as u64),
2400            )
2401        });
2402        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
2403            let chunk = &entry.chunk;
2404            bytes
2405                .saturating_add(crate::memory::path_bytes(&chunk.file))
2406                .saturating_add(crate::memory::usize_to_u64(chunk.name.len()))
2407                .saturating_add(
2408                    chunk
2409                        .qualified_name
2410                        .as_ref()
2411                        .map(|name| crate::memory::usize_to_u64(name.len()))
2412                        .unwrap_or(0),
2413                )
2414                .saturating_add(crate::memory::usize_to_u64(chunk.embed_text.len()))
2415                .saturating_add(crate::memory::usize_to_u64(chunk.snippet.len()))
2416        });
2417        let entry_metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
2418            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64);
2419        let file_metadata_bytes = self
2420            .file_mtimes
2421            .keys()
2422            .chain(self.file_sizes.keys())
2423            .chain(self.file_hashes.keys())
2424            .chain(self.deferred_files.iter())
2425            .map(|path| crate::memory::path_bytes(path))
2426            .fold(0u64, u64::saturating_add)
2427            .saturating_add(
2428                crate::memory::usize_to_u64(self.file_mtimes.len())
2429                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
2430            )
2431            .saturating_add(
2432                crate::memory::usize_to_u64(self.file_sizes.len())
2433                    .saturating_mul(std::mem::size_of::<u64>() as u64),
2434            )
2435            .saturating_add(
2436                crate::memory::usize_to_u64(self.file_hashes.len())
2437                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
2438            );
2439        let index_metadata_bytes = crate::memory::path_bytes(&self.project_root).saturating_add(
2440            self.fingerprint
2441                .as_ref()
2442                .map(|fingerprint| {
2443                    crate::memory::usize_to_u64(fingerprint.backend.len())
2444                        .saturating_add(crate::memory::usize_to_u64(fingerprint.model.len()))
2445                        .saturating_add(crate::memory::usize_to_u64(fingerprint.base_url.len()))
2446                })
2447                .unwrap_or(0),
2448        );
2449        let metadata_bytes = entry_metadata_bytes
2450            .saturating_add(file_metadata_bytes)
2451            .saturating_add(index_metadata_bytes);
2452        let entry_count = crate::memory::usize_to_u64(self.entries.len());
2453        crate::memory::MemoryEstimate::estimated(
2454            vector_bytes
2455                .saturating_add(text_bytes)
2456                .saturating_add(metadata_bytes),
2457        )
2458        .count("entries", self.entries.len())
2459        .count("dimensions", self.dimension)
2460        .count("indexed_files", self.file_mtimes.len())
2461        .count_u64("vector_bytes", vector_bytes)
2462        .count_u64("text_bytes", text_bytes)
2463        .count_u64("metadata_bytes", metadata_bytes)
2464        .count_u64(
2465            "average_text_bytes",
2466            text_bytes.checked_div(entry_count).unwrap_or(0),
2467        )
2468        .count_u64(
2469            "average_metadata_bytes",
2470            metadata_bytes.checked_div(entry_count).unwrap_or(0),
2471        )
2472    }
2473
2474    /// Number of files currently tracked by the semantic index.
2475    pub fn indexed_file_count(&self) -> usize {
2476        self.shared_base
2477            .as_ref()
2478            .map(|base| base.file_mtimes.len())
2479            .unwrap_or_else(|| self.file_mtimes.len())
2480    }
2481
2482    /// Human-readable status label for the index.
2483    pub fn status_label(&self) -> &'static str {
2484        if self.entry_count() == 0 {
2485            "empty"
2486        } else {
2487            "ready"
2488        }
2489    }
2490
2491    fn collect_chunks(
2492        project_root: &Path,
2493        files: &[PathBuf],
2494    ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
2495        let collect_started = Instant::now();
2496        let collect_one = |file: &Path, sched: Duration| {
2497            let mut phases = SemanticCollectPhaseTimings {
2498                sched,
2499                ..SemanticCollectPhaseTimings::default()
2500            };
2501            let result = collect_semantic_file(project_root, file, &mut phases);
2502            (file.to_path_buf(), result, phases)
2503        };
2504        let per_file: Vec<CollectedSemanticFile> = if files.len() <= 2 {
2505            files
2506                .iter()
2507                .map(|file| collect_one(file, Duration::ZERO))
2508                .collect()
2509        } else {
2510            files
2511                .par_iter()
2512                .map(|file| collect_one(file, collect_started.elapsed()))
2513                .collect()
2514        };
2515
2516        let mut chunks: Vec<SemanticChunk> = Vec::new();
2517        let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
2518        let mut phases = SemanticCollectPhaseTimings::default();
2519
2520        for (file, result, file_phases) in per_file {
2521            phases.add_assign(file_phases);
2522            match result {
2523                Ok((metadata, file_chunks)) => {
2524                    file_metadata.insert(file, metadata);
2525                    chunks.extend(file_chunks);
2526                }
2527                Err(error) => {
2528                    // "unsupported file extension" is expected for non-code files
2529                    // (json, xml, .gitignore, etc.) that get included in the
2530                    // project walk. Pre-fix this was swallowed by .unwrap_or_default();
2531                    // we now skip silently to keep the log clean. Only real read/parse
2532                    // errors are worth surfacing.
2533                    if error == "unsupported file extension" {
2534                        continue;
2535                    }
2536                    slog_warn!(
2537                        "failed to collect semantic chunks for {}: {}",
2538                        file.display(),
2539                        error
2540                    );
2541                }
2542            }
2543        }
2544
2545        let collect_ms = collect_started
2546            .elapsed()
2547            .as_millis()
2548            .min(u128::from(u64::MAX)) as u64;
2549        crate::logging::note_semantic_collect(chunks.len(), file_metadata.len(), collect_ms);
2550        slog_info!(
2551            "semantic collect: {} chunks from {} files in {} ms",
2552            chunks.len(),
2553            file_metadata.len(),
2554            collect_ms
2555        );
2556        if let Some(scope) = crate::logging::current_index_build() {
2557            if scope.plane == crate::logging::IndexPlane::Semantic {
2558                crate::logging::log_index_event(
2559                    crate::logging::IndexEvent::from_scope(
2560                        crate::logging::IndexEventKind::BuildProgress,
2561                        &scope,
2562                    )
2563                    .field("stage", "collect")
2564                    .field("completed", 1)
2565                    .field("total", 1)
2566                    .field("elapsed_ms", scope.elapsed_ms())
2567                    .field("chunks", chunks.len())
2568                    .field("files", file_metadata.len()),
2569                );
2570            }
2571        }
2572        if collect_ms > 50 {
2573            slog_info!(
2574                "semantic collect phases: sched={}ms read_hash={}ms parse={}ms extract={}ms build={}ms",
2575                phases.sched.as_millis(),
2576                phases.read_hash.as_millis(),
2577                phases.parse.as_millis(),
2578                phases.extract.as_millis(),
2579                phases.build.as_millis(),
2580            );
2581        }
2582
2583        (chunks, file_metadata)
2584    }
2585
2586    fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
2587        let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2588        let mut reuse_map: ChunkReuseMap = HashMap::new();
2589
2590        for entry in &self.entries {
2591            if !requested.contains(entry.chunk.file.as_path()) {
2592                continue;
2593            }
2594
2595            // `embed_text` is already persisted in the current on-disk format,
2596            // so refresh-time reuse can hash it in memory and confirm the exact
2597            // string without bumping `SEMANTIC_INDEX_VERSION` and forcing every
2598            // user through a full rebuild.
2599            let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
2600            reuse_map
2601                .entry(entry.chunk.file.clone())
2602                .or_default()
2603                .entry(hash)
2604                .or_default()
2605                .push(ReusableEmbedding {
2606                    embed_text: entry.chunk.embed_text.clone(),
2607                    vector: entry.vector.clone(),
2608                });
2609        }
2610
2611        reuse_map
2612    }
2613
2614    fn reusable_vector_for_chunk(
2615        reuse_map: &ChunkReuseMap,
2616        chunk: &SemanticChunk,
2617    ) -> Option<Vec<f32>> {
2618        let hash = blake3::hash(chunk.embed_text.as_bytes());
2619        reuse_map
2620            .get(&chunk.file)?
2621            .get(&hash)?
2622            .iter()
2623            .find(|candidate| candidate.embed_text == chunk.embed_text)
2624            .map(|candidate| candidate.vector.clone())
2625    }
2626
2627    fn entries_for_chunks_with_reuse<F, P>(
2628        chunks: Vec<SemanticChunk>,
2629        reuse_map: &ChunkReuseMap,
2630        embed_fn: &mut F,
2631        max_batch_size: usize,
2632        initial_observed_dimension: Option<usize>,
2633        refresh_label: &str,
2634        progress: &mut P,
2635    ) -> Result<(Vec<EmbeddingEntry>, Option<usize>), String>
2636    where
2637        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2638        P: FnMut(usize, usize),
2639    {
2640        let total_chunks = chunks.len();
2641        progress(0, total_chunks);
2642
2643        let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
2644        let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
2645
2646        for (chunk_index, chunk) in chunks.into_iter().enumerate() {
2647            if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
2648                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2649            } else {
2650                misses.push((chunk_index, chunk));
2651            }
2652        }
2653
2654        let mut completed = total_chunks.saturating_sub(misses.len());
2655        if completed > 0 {
2656            progress(completed, total_chunks);
2657        }
2658
2659        let batch_size = max_batch_size.max(1);
2660        let mut observed_dimension = initial_observed_dimension;
2661
2662        for batch_start in (0..misses.len()).step_by(batch_size) {
2663            let batch_end = (batch_start + batch_size).min(misses.len());
2664            let batch_texts: Vec<String> = misses[batch_start..batch_end]
2665                .iter()
2666                .map(|(_, chunk)| chunk.embed_text.clone())
2667                .collect();
2668
2669            let vectors = embed_fn(batch_texts)?;
2670            validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2671
2672            if let Some(dim) = vectors.first().map(|vector| vector.len()) {
2673                match observed_dimension {
2674                    None => observed_dimension = Some(dim),
2675                    Some(expected) if dim != expected => {
2676                        return Err(format!(
2677                            "embedding dimension changed during {refresh_label}: \
2678                             cached index uses {expected}, new vectors use {dim}"
2679                        ));
2680                    }
2681                    _ => {}
2682                }
2683            }
2684
2685            for (i, vector) in vectors.into_iter().enumerate() {
2686                let (chunk_index, chunk) = misses[batch_start + i].clone();
2687                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2688            }
2689
2690            completed += batch_end - batch_start;
2691            progress(completed, total_chunks);
2692        }
2693
2694        let entries = entries_by_chunk
2695            .into_iter()
2696            .map(|entry| entry.expect("semantic refresh accounted for every chunk"))
2697            .collect();
2698
2699        Ok((entries, observed_dimension))
2700    }
2701
2702    fn build_from_chunks<F, P, C>(
2703        project_root: &Path,
2704        chunks: Vec<SemanticChunk>,
2705        file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
2706        embed_fn: &mut F,
2707        max_batch_size: usize,
2708        mut progress: Option<&mut P>,
2709        should_continue: &mut C,
2710    ) -> Result<Self, String>
2711    where
2712        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2713        P: FnMut(usize, usize),
2714        C: FnMut() -> bool,
2715    {
2716        debug_assert!(project_root.is_absolute());
2717        let total_chunks = chunks.len();
2718
2719        if chunks.is_empty() {
2720            return Ok(Self {
2721                entries: Vec::new(),
2722                file_mtimes: file_metadata
2723                    .iter()
2724                    .map(|(path, metadata)| (path.clone(), metadata.mtime))
2725                    .collect(),
2726                file_sizes: file_metadata
2727                    .iter()
2728                    .map(|(path, metadata)| (path.clone(), metadata.size))
2729                    .collect(),
2730                any_missing_sizes: false,
2731                file_hashes: file_metadata
2732                    .into_iter()
2733                    .map(|(path, metadata)| (path, metadata.content_hash))
2734                    .collect(),
2735                dimension: DEFAULT_DIMENSION,
2736                fingerprint: None,
2737                project_root: project_root.to_path_buf(),
2738                deferred_files: HashSet::new(),
2739                shared_base: None,
2740                #[cfg(test)]
2741                removal_retain_passes: 0,
2742            });
2743        }
2744
2745        // Embed in batches
2746        let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
2747        let mut expected_dimension: Option<usize> = None;
2748        let batch_size = max_batch_size.max(1);
2749        let embed_started = std::time::Instant::now();
2750        let batch_count = total_chunks.div_ceil(batch_size);
2751        for (batch_index, batch_start) in (0..chunks.len()).step_by(batch_size).enumerate() {
2752            if !should_continue() {
2753                slog_info!(
2754                    "semantic embed superseded, stopping after {}/{} batches",
2755                    batch_index,
2756                    batch_count
2757                );
2758                return Err(format!(
2759                    "semantic build superseded after {batch_index}/{batch_count} batches"
2760                ));
2761            }
2762            let batch_end = (batch_start + batch_size).min(chunks.len());
2763            let batch_texts: Vec<String> = chunks[batch_start..batch_end]
2764                .iter()
2765                .map(|c| c.embed_text.clone())
2766                .collect();
2767
2768            let vectors = embed_fn(batch_texts)?;
2769            validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2770
2771            // Track consistent dimension across all batches
2772            if let Some(dim) = vectors.first().map(|v| v.len()) {
2773                match expected_dimension {
2774                    None => expected_dimension = Some(dim),
2775                    Some(expected) if dim != expected => {
2776                        return Err(format!(
2777                            "embedding dimension changed across batches: expected {expected}, got {dim}"
2778                        ));
2779                    }
2780                    _ => {}
2781                }
2782            }
2783
2784            for (i, vector) in vectors.into_iter().enumerate() {
2785                let chunk_idx = batch_start + i;
2786                entries.push(EmbeddingEntry::new(chunks[chunk_idx].clone(), vector));
2787            }
2788
2789            if let Some(callback) = progress.as_mut() {
2790                callback(entries.len(), total_chunks);
2791            }
2792            if let Some(scope) = crate::logging::current_index_build() {
2793                if scope.plane == crate::logging::IndexPlane::Semantic {
2794                    crate::logging::log_index_event(
2795                        crate::logging::IndexEvent::from_scope(
2796                            crate::logging::IndexEventKind::BuildProgress,
2797                            &scope,
2798                        )
2799                        .field("stage", "embed")
2800                        .field("batch", batch_index + 1)
2801                        .field("total_batches", batch_count)
2802                        .field("chunks_done", entries.len())
2803                        .field("completed", entries.len())
2804                        .field("total", total_chunks)
2805                        .field("elapsed_ms", scope.elapsed_ms()),
2806                    );
2807                }
2808            }
2809            if (batch_index + 1) % 25 == 0 {
2810                slog_info!(
2811                    "semantic embed progress: batch {}/{} ({} / {} chunks)",
2812                    batch_index + 1,
2813                    batch_count,
2814                    entries.len(),
2815                    total_chunks
2816                );
2817            }
2818        }
2819
2820        let embed_ms = embed_started.elapsed().as_millis();
2821        let rate = (total_chunks as u128 * 1000)
2822            .checked_div(embed_ms)
2823            .unwrap_or(0) as u64;
2824        slog_info!(
2825            "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s)",
2826            total_chunks,
2827            batch_count,
2828            embed_ms,
2829            rate
2830        );
2831
2832        let dimension = entries
2833            .first()
2834            .map(|e| e.vector.len())
2835            .unwrap_or(DEFAULT_DIMENSION);
2836
2837        Ok(Self {
2838            entries,
2839            file_mtimes: file_metadata
2840                .iter()
2841                .map(|(path, metadata)| (path.clone(), metadata.mtime))
2842                .collect(),
2843            file_sizes: file_metadata
2844                .iter()
2845                .map(|(path, metadata)| (path.clone(), metadata.size))
2846                .collect(),
2847            any_missing_sizes: false,
2848            file_hashes: file_metadata
2849                .into_iter()
2850                .map(|(path, metadata)| (path, metadata.content_hash))
2851                .collect(),
2852            dimension,
2853            fingerprint: None,
2854            project_root: project_root.to_path_buf(),
2855            deferred_files: HashSet::new(),
2856            shared_base: None,
2857            #[cfg(test)]
2858            removal_retain_passes: 0,
2859        })
2860    }
2861
2862    /// Build the semantic index from a set of files using the provided embedding function.
2863    /// `embed_fn` takes a batch of texts and returns a batch of embedding vectors.
2864    pub fn build<F>(
2865        project_root: &Path,
2866        files: &[PathBuf],
2867        embed_fn: &mut F,
2868        max_batch_size: usize,
2869    ) -> Result<Self, String>
2870    where
2871        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2872    {
2873        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
2874        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2875        let mut should_continue = || true;
2876        let result = Self::build_from_chunks(
2877            project_root,
2878            chunks,
2879            file_mtimes,
2880            embed_fn,
2881            max_batch_size,
2882            Option::<&mut fn(usize, usize)>::None,
2883            &mut should_continue,
2884        );
2885        finish_semantic_index_build(&scope, &mut failure_guard, &result);
2886        result
2887    }
2888
2889    /// Build the semantic index and report embedding progress using entry counts.
2890    pub fn build_with_progress<F, P>(
2891        project_root: &Path,
2892        files: &[PathBuf],
2893        embed_fn: &mut F,
2894        max_batch_size: usize,
2895        progress: &mut P,
2896    ) -> Result<Self, String>
2897    where
2898        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2899        P: FnMut(usize, usize),
2900    {
2901        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
2902        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2903        let total_chunks = chunks.len();
2904        progress(0, total_chunks);
2905        let mut should_continue = || true;
2906        let result = Self::build_from_chunks(
2907            project_root,
2908            chunks,
2909            file_mtimes,
2910            embed_fn,
2911            max_batch_size,
2912            Some(progress),
2913            &mut should_continue,
2914        );
2915        finish_semantic_index_build(&scope, &mut failure_guard, &result);
2916        result
2917    }
2918
2919    /// Build the semantic index while checking cancellation before every embed
2920    /// batch. A batch already in flight is allowed to finish, then the partial
2921    /// result is discarded before the next request can start.
2922    pub fn build_with_progress_and_cancellation<F, P, C>(
2923        project_root: &Path,
2924        files: &[PathBuf],
2925        embed_fn: &mut F,
2926        max_batch_size: usize,
2927        progress: &mut P,
2928        should_continue: &mut C,
2929    ) -> Result<Self, String>
2930    where
2931        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2932        P: FnMut(usize, usize),
2933        C: FnMut() -> bool,
2934    {
2935        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
2936        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2937        let total_chunks = chunks.len();
2938        progress(0, total_chunks);
2939        let result = Self::build_from_chunks(
2940            project_root,
2941            chunks,
2942            file_mtimes,
2943            embed_fn,
2944            max_batch_size,
2945            Some(progress),
2946            should_continue,
2947        );
2948        finish_semantic_index_build(&scope, &mut failure_guard, &result);
2949        result
2950    }
2951
2952    /// Incrementally refresh entries for changed/new files only, preserving cached
2953    /// embeddings for unchanged files. Used when loading the index from disk and
2954    /// finding that a small fraction of files have moved on, deleted, or appeared.
2955    ///
2956    /// Returns `RefreshSummary` describing what changed. On success, `self` is
2957    /// mutated in place and remains a valid index.
2958    ///
2959    /// `current_files` is the full set of files the project considers indexable
2960    /// (typically `walk_project_files(...)`). Files in the cache that are no
2961    /// longer in this set are treated as deleted.
2962    pub fn refresh_stale_files<F, P>(
2963        &mut self,
2964        project_root: &Path,
2965        current_files: &[PathBuf],
2966        embed_fn: &mut F,
2967        max_batch_size: usize,
2968        progress: &mut P,
2969    ) -> Result<RefreshSummary, String>
2970    where
2971        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2972        P: FnMut(usize, usize),
2973    {
2974        self.refresh_stale_files_with_strategy(
2975            project_root,
2976            current_files,
2977            embed_fn,
2978            max_batch_size,
2979            progress,
2980            cache_freshness::VerifyStrategy::Strict,
2981        )
2982    }
2983
2984    pub(crate) fn refresh_stale_files_with_strategy<F, P>(
2985        &mut self,
2986        project_root: &Path,
2987        current_files: &[PathBuf],
2988        embed_fn: &mut F,
2989        max_batch_size: usize,
2990        progress: &mut P,
2991        verify_strategy: cache_freshness::VerifyStrategy,
2992    ) -> Result<RefreshSummary, String>
2993    where
2994        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2995        P: FnMut(usize, usize),
2996    {
2997        self.materialize_shared_base();
2998        self.backfill_missing_file_sizes();
2999
3000        // 1. Bucket files into deleted / changed / added.
3001        let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
3002        self.deferred_files
3003            .retain(|path| current_set.contains(path.as_path()));
3004        let total_processed = current_set.len() + self.file_mtimes.len()
3005            - self
3006                .file_mtimes
3007                .keys()
3008                .filter(|path| current_set.contains(path.as_path()))
3009                .count();
3010
3011        // Files in cache that disappeared from disk OR are no longer in the
3012        // walked set. Both cases need their entries dropped.
3013        enum IndexedFileCheck {
3014            Deleted(PathBuf),
3015            MissingMetadata(PathBuf),
3016            Verified(PathBuf, FreshnessVerdict),
3017        }
3018
3019        let mut deleted: Vec<PathBuf> = Vec::new();
3020        let mut changed: Vec<PathBuf> = Vec::new();
3021        let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
3022        let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
3023        let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
3024
3025        for indexed_path in indexed_paths {
3026            let check_index = checks.len();
3027            if !current_set.contains(indexed_path.as_path()) {
3028                checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
3029                continue;
3030            }
3031            let cached = match (
3032                self.file_mtimes.get(&indexed_path),
3033                self.file_sizes.get(&indexed_path),
3034                self.file_hashes.get(&indexed_path),
3035            ) {
3036                (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
3037                    mtime: *mtime,
3038                    size: *size,
3039                    content_hash: *hash,
3040                }),
3041                _ => None,
3042            };
3043            if let Some(freshness) = cached {
3044                strict_verify_inputs.push((check_index, indexed_path, freshness));
3045                checks.push(None);
3046            } else {
3047                checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
3048            }
3049        }
3050
3051        let verified = match verify_strategy {
3052            cache_freshness::VerifyStrategy::StatFirst => cache_freshness::verify_files_bounded(
3053                strict_verify_inputs,
3054                cache_freshness::VerifyStrategy::StatFirst,
3055            ),
3056            cache_freshness::VerifyStrategy::Strict => {
3057                cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
3058            }
3059        };
3060        for (check_index, path, verdict) in verified {
3061            checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
3062        }
3063
3064        for check in checks {
3065            match check.expect("freshness check should be populated") {
3066                IndexedFileCheck::Deleted(path) => deleted.push(path),
3067                IndexedFileCheck::MissingMetadata(path) => changed.push(path),
3068                IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
3069                IndexedFileCheck::Verified(
3070                    path,
3071                    FreshnessVerdict::ContentFresh {
3072                        new_mtime,
3073                        new_size,
3074                    },
3075                ) => {
3076                    self.file_mtimes.insert(path.clone(), new_mtime);
3077                    self.file_sizes.insert(path, new_size);
3078                }
3079                IndexedFileCheck::Verified(
3080                    path,
3081                    FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
3082                ) => {
3083                    changed.push(path);
3084                }
3085            }
3086        }
3087
3088        // Files in walk that were never indexed.
3089        let mut added: Vec<PathBuf> = Vec::new();
3090        for path in current_files {
3091            if !self.file_mtimes.contains_key(path) {
3092                added.push(path.clone());
3093            }
3094        }
3095
3096        // Fast path: nothing to do.
3097        if deleted.is_empty() && changed.is_empty() && added.is_empty() {
3098            progress(0, 0);
3099            return Ok(RefreshSummary {
3100                total_processed,
3101                ..RefreshSummary::default()
3102            });
3103        }
3104
3105        // 2. Drop entries for deleted files immediately. Changed files are only
3106        //    replaced after successful re-extraction + embedding so transient
3107        //    read/parse errors keep the stale-but-valid cache entry.
3108        if !deleted.is_empty() {
3109            self.remove_indexed_files(&deleted);
3110        }
3111
3112        // 3. Embed the changed + added set, if any.
3113        let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
3114        to_embed.extend(changed.iter().cloned());
3115        to_embed.extend(added.iter().cloned());
3116
3117        if to_embed.is_empty() {
3118            // Only deletions happened.
3119            progress(0, 0);
3120            return Ok(RefreshSummary {
3121                changed: 0,
3122                added: 0,
3123                deleted: deleted.len(),
3124                total_processed,
3125            });
3126        }
3127
3128        let reuse_map = self.build_chunk_reuse_map(&changed);
3129        let (chunks, fresh_metadata) = Self::collect_chunks(project_root, &to_embed);
3130        let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
3131        let vanished = to_embed
3132            .iter()
3133            .filter(|path| {
3134                changed_set.contains(path.as_path())
3135                    && !fresh_metadata.contains_key(*path)
3136                    && !path.exists()
3137            })
3138            .cloned()
3139            .collect::<Vec<_>>();
3140        if !vanished.is_empty() {
3141            self.remove_indexed_files(&vanished);
3142            deleted.extend(vanished);
3143        }
3144
3145        if chunks.is_empty() {
3146            progress(0, 0);
3147            let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3148            for file in &successful_files {
3149                self.deferred_files.remove(file);
3150            }
3151            if !successful_files.is_empty() {
3152                self.entries
3153                    .retain(|entry| !successful_files.contains(&entry.chunk.file));
3154            }
3155            let changed_count = changed
3156                .iter()
3157                .filter(|path| successful_files.contains(*path))
3158                .count();
3159            let added_count = added
3160                .iter()
3161                .filter(|path| successful_files.contains(*path))
3162                .count();
3163            for (file, metadata) in fresh_metadata {
3164                self.file_mtimes.insert(file.clone(), metadata.mtime);
3165                self.file_sizes.insert(file.clone(), metadata.size);
3166                self.file_hashes.insert(file.clone(), metadata.content_hash);
3167            }
3168            return Ok(RefreshSummary {
3169                changed: changed_count,
3170                added: added_count,
3171                deleted: deleted.len(),
3172                total_processed,
3173            });
3174        }
3175
3176        // 4. Build the full replacement set, reusing cached vectors for chunks
3177        //    whose embed_text is unchanged and embedding only cache misses.
3178        let existing_dimension = if self.entries.is_empty() {
3179            None
3180        } else {
3181            Some(self.dimension)
3182        };
3183        let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
3184            chunks,
3185            &reuse_map,
3186            embed_fn,
3187            max_batch_size,
3188            existing_dimension,
3189            "incremental refresh",
3190            progress,
3191        )?;
3192
3193        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3194        for file in &successful_files {
3195            self.deferred_files.remove(file);
3196        }
3197        if !successful_files.is_empty() {
3198            self.entries
3199                .retain(|entry| !successful_files.contains(&entry.chunk.file));
3200        }
3201
3202        self.entries.extend(new_entries);
3203        for (file, metadata) in fresh_metadata {
3204            self.file_mtimes.insert(file.clone(), metadata.mtime);
3205            self.file_sizes.insert(file.clone(), metadata.size);
3206            self.file_hashes.insert(file, metadata.content_hash);
3207        }
3208        if let Some(dim) = observed_dimension {
3209            self.dimension = dim;
3210        }
3211
3212        Ok(RefreshSummary {
3213            changed: changed
3214                .iter()
3215                .filter(|path| successful_files.contains(*path))
3216                .count(),
3217            added: added
3218                .iter()
3219                .filter(|path| successful_files.contains(*path))
3220                .count(),
3221            deleted: deleted.len(),
3222            total_processed,
3223        })
3224    }
3225
3226    /// Refresh exactly the files invalidated by the live watcher, without
3227    /// treating the provided path list as the whole project. This is the
3228    /// watcher-side counterpart to `refresh_stale_files`: it drops any stale
3229    /// entries for the requested paths from this in-memory index, re-extracts
3230    /// whatever still exists on disk, embeds those chunks, and returns the
3231    /// delta needed for another in-memory index to apply the same update.
3232    pub fn refresh_invalidated_files<F, P>(
3233        &mut self,
3234        project_root: &Path,
3235        paths: &[PathBuf],
3236        embed_fn: &mut F,
3237        max_batch_size: usize,
3238        max_files: usize,
3239        progress: &mut P,
3240    ) -> Result<InvalidatedFilesRefresh, String>
3241    where
3242        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3243        P: FnMut(usize, usize),
3244    {
3245        self.materialize_shared_base();
3246        self.backfill_missing_file_sizes();
3247
3248        self.deferred_files.retain(|path| path.exists());
3249        let mut requested_paths = paths.to_vec();
3250        requested_paths.extend(self.deferred_files.iter().cloned());
3251        requested_paths.sort();
3252        requested_paths.dedup();
3253        let total_processed = requested_paths.len();
3254
3255        if requested_paths.is_empty() {
3256            progress(0, 0);
3257            return Ok(InvalidatedFilesRefresh {
3258                summary: RefreshSummary {
3259                    total_processed,
3260                    ..RefreshSummary::default()
3261                },
3262                ..InvalidatedFilesRefresh::default()
3263            });
3264        }
3265
3266        let previously_indexed: HashSet<PathBuf> = requested_paths
3267            .iter()
3268            .filter(|path| self.file_mtimes.contains_key(*path))
3269            .cloned()
3270            .collect();
3271        let reuse_map = self.build_chunk_reuse_map(&requested_paths);
3272
3273        // The watcher path has already invalidated these files in the request
3274        // thread's live index. Mirror that behavior here before inserting any
3275        // fresh chunks so parse/read failures do not resurrect stale entries.
3276        self.remove_indexed_files(&requested_paths);
3277
3278        let existing_paths = requested_paths
3279            .iter()
3280            .filter(|path| path.exists())
3281            .cloned()
3282            .collect::<Vec<_>>();
3283        let deleted = requested_paths
3284            .iter()
3285            .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
3286            .count();
3287
3288        if existing_paths.is_empty() {
3289            for path in &requested_paths {
3290                if !path.exists() {
3291                    self.deferred_files.remove(path);
3292                }
3293            }
3294            progress(0, 0);
3295            return Ok(InvalidatedFilesRefresh {
3296                completed_paths: requested_paths,
3297                summary: RefreshSummary {
3298                    deleted,
3299                    total_processed,
3300                    ..RefreshSummary::default()
3301                },
3302                ..InvalidatedFilesRefresh::default()
3303            });
3304        }
3305
3306        let (mut chunks, mut fresh_metadata) = Self::collect_chunks(project_root, &existing_paths);
3307
3308        let retained_file_count = self.file_mtimes.len();
3309        let changed_successful_count = existing_paths
3310            .iter()
3311            .filter(|path| {
3312                previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
3313            })
3314            .count();
3315        let available_new_files =
3316            max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
3317        let new_successful_files = existing_paths
3318            .iter()
3319            .filter(|path| {
3320                !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
3321            })
3322            .cloned()
3323            .collect::<Vec<_>>();
3324        if new_successful_files.len() > available_new_files {
3325            let allowed_new_files = new_successful_files
3326                .iter()
3327                .take(available_new_files)
3328                .cloned()
3329                .collect::<HashSet<_>>();
3330            let deferred_new_files = new_successful_files
3331                .into_iter()
3332                .filter(|path| !allowed_new_files.contains(path))
3333                .collect::<HashSet<_>>();
3334
3335            fresh_metadata.retain(|file, _| {
3336                previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
3337            });
3338            chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
3339
3340            if !deferred_new_files.is_empty() {
3341                for path in &deferred_new_files {
3342                    self.deferred_files.insert(path.clone());
3343                }
3344                slog_warn!(
3345                    "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
3346                    deferred_new_files.len(),
3347                    max_files
3348                );
3349            }
3350        }
3351
3352        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3353        for file in &successful_files {
3354            self.deferred_files.remove(file);
3355        }
3356        let changed = successful_files
3357            .iter()
3358            .filter(|path| previously_indexed.contains(path.as_path()))
3359            .count();
3360        let added = successful_files.len().saturating_sub(changed);
3361        let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
3362
3363        if chunks.is_empty() {
3364            progress(0, 0);
3365            for (file, metadata) in fresh_metadata {
3366                let freshness = FileFreshness {
3367                    mtime: metadata.mtime,
3368                    size: metadata.size,
3369                    content_hash: metadata.content_hash,
3370                };
3371                self.file_mtimes.insert(file.clone(), freshness.mtime);
3372                self.file_sizes.insert(file.clone(), freshness.size);
3373                self.file_hashes
3374                    .insert(file.clone(), freshness.content_hash);
3375                updated_metadata.push((file, freshness));
3376            }
3377
3378            return Ok(InvalidatedFilesRefresh {
3379                updated_metadata,
3380                completed_paths: requested_paths,
3381                summary: RefreshSummary {
3382                    changed,
3383                    added,
3384                    deleted,
3385                    total_processed,
3386                },
3387                ..InvalidatedFilesRefresh::default()
3388            });
3389        }
3390
3391        let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
3392        {
3393            None
3394        } else {
3395            Some(self.dimension)
3396        };
3397        let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
3398            chunks,
3399            &reuse_map,
3400            embed_fn,
3401            max_batch_size,
3402            initial_observed_dimension,
3403            "invalidated-file refresh",
3404            progress,
3405        )?;
3406
3407        let added_entries = new_entries.clone();
3408        self.entries.extend(new_entries);
3409        for (file, metadata) in fresh_metadata {
3410            let freshness = FileFreshness {
3411                mtime: metadata.mtime,
3412                size: metadata.size,
3413                content_hash: metadata.content_hash,
3414            };
3415            self.file_mtimes.insert(file.clone(), freshness.mtime);
3416            self.file_sizes.insert(file.clone(), freshness.size);
3417            self.file_hashes
3418                .insert(file.clone(), freshness.content_hash);
3419            updated_metadata.push((file, freshness));
3420        }
3421        if let Some(dim) = observed_dimension {
3422            self.dimension = dim;
3423        }
3424
3425        Ok(InvalidatedFilesRefresh {
3426            added_entries,
3427            updated_metadata,
3428            completed_paths: requested_paths,
3429            summary: RefreshSummary {
3430                changed,
3431                added,
3432                deleted,
3433                total_processed,
3434            },
3435        })
3436    }
3437
3438    pub fn apply_refresh_update(
3439        &mut self,
3440        added_entries: Vec<EmbeddingEntry>,
3441        updated_metadata: Vec<(PathBuf, FileFreshness)>,
3442        completed_paths: &[PathBuf],
3443    ) {
3444        self.materialize_shared_base();
3445        // `added_entries` is the complete replacement set for completed paths:
3446        // freshly embedded misses plus reused chunks carrying refreshed metadata.
3447        // Removing first is safe only because producers include both kinds.
3448        self.remove_indexed_files(completed_paths);
3449
3450        let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
3451        self.entries.extend(added_entries);
3452        for (file, freshness) in updated_metadata {
3453            self.file_mtimes.insert(file.clone(), freshness.mtime);
3454            self.file_sizes.insert(file.clone(), freshness.size);
3455            self.file_hashes.insert(file, freshness.content_hash);
3456        }
3457        if let Some(dim) = observed_dimension {
3458            self.dimension = dim;
3459        }
3460    }
3461
3462    fn remove_indexed_file_keys(
3463        &mut self,
3464        entry_files: &HashSet<PathBuf>,
3465        metadata_files: &[PathBuf],
3466    ) {
3467        #[cfg(test)]
3468        {
3469            self.removal_retain_passes += 1;
3470        }
3471        self.entries
3472            .retain(|entry| !entry_files.contains(&entry.chunk.file));
3473        for path in metadata_files {
3474            self.file_mtimes.remove(path);
3475            self.file_sizes.remove(path);
3476            self.file_hashes.remove(path);
3477        }
3478    }
3479
3480    fn remove_indexed_files(&mut self, files: &[PathBuf]) {
3481        let deleted_set = files.iter().cloned().collect();
3482        self.remove_indexed_file_keys(&deleted_set, files);
3483    }
3484
3485    /// Search the index with a query embedding, returning top-K results sorted by relevance.
3486    pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
3487        self.search_filtered(query_vector, top_k, |_| true)
3488    }
3489
3490    /// Search only entries whose resolved source path satisfies `include`.
3491    ///
3492    /// Filtering before top-K selection prevents excluded files from consuming the
3493    /// bounded candidate window and hiding lower-ranked eligible results.
3494    pub(crate) fn search_filtered<F>(
3495        &self,
3496        query_vector: &[f32],
3497        top_k: usize,
3498        include: F,
3499    ) -> Vec<SemanticResult>
3500    where
3501        F: Fn(&Path) -> bool,
3502    {
3503        let (entries, dimension) = self
3504            .shared_base
3505            .as_ref()
3506            .map(|base| (base.entries.as_slice(), base.dimension))
3507            .unwrap_or_else(|| (self.entries.as_slice(), self.dimension));
3508        if entries.is_empty() || query_vector.len() != dimension {
3509            return Vec::new();
3510        }
3511
3512        // Query norms are shared by every entry; entry norms are cached because
3513        // remote embedding backends may return non-normalized vectors.
3514        let query_norm = vector_norm(query_vector);
3515        let cancellation = crate::executor::current_job_cancellation();
3516        let mut scored: Vec<(f32, usize)> = Vec::with_capacity(entries.len());
3517        for (i, entry) in entries.iter().enumerate() {
3518            if i % 64 == 0
3519                && cancellation
3520                    .as_ref()
3521                    .is_some_and(|token| token.cancel_requested_before_commit())
3522            {
3523                break;
3524            }
3525            let included = if self.shared_base.is_some() {
3526                include(&self.project_root.join(&entry.chunk.file))
3527            } else {
3528                include(&entry.chunk.file)
3529            };
3530            if !included {
3531                continue;
3532            }
3533
3534            let dot = if query_vector.len() == entry.vector.len() {
3535                dot_product(query_vector, &entry.vector)
3536            } else {
3537                0.0
3538            };
3539            let denom = query_norm * entry.norm;
3540            let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
3541            if entry.chunk.exported {
3542                score *= 1.1;
3543            }
3544            scored.push((score, i));
3545        }
3546
3547        let keep = top_k.min(scored.len());
3548        if keep == 0 {
3549            return Vec::new();
3550        }
3551
3552        if keep < scored.len() {
3553            scored.select_nth_unstable_by(keep, semantic_score_order);
3554            scored.truncate(keep);
3555        }
3556        scored.sort_by(semantic_score_order);
3557
3558        scored
3559            .into_iter()
3560            // Keep the selected best-first slice mapped without reintroducing the
3561            // old `> 0.0` floor: top_k has already been selected, and zero-score
3562            // tail entries remain observable when requested.
3563            .map(|(score, idx)| {
3564                let entry = &entries[idx];
3565                SemanticResult {
3566                    file: if self.shared_base.is_some() {
3567                        self.project_root.join(&entry.chunk.file)
3568                    } else {
3569                        entry.chunk.file.clone()
3570                    },
3571                    name: entry.chunk.name.clone(),
3572                    qualified_name: entry.chunk.qualified_name.clone(),
3573                    kind: entry.chunk.kind.clone(),
3574                    start_line: entry.chunk.start_line,
3575                    end_line: entry.chunk.end_line,
3576                    exported: entry.chunk.exported,
3577                    snippet: entry.chunk.snippet.clone(),
3578                    score,
3579                    rank_score: score,
3580                    cap_protected: false,
3581                    source: "semantic",
3582                }
3583            })
3584            .collect()
3585    }
3586
3587    /// Number of indexed entries
3588    pub fn len(&self) -> usize {
3589        self.entry_count()
3590    }
3591
3592    /// Check if a file needs re-indexing based on mtime/size
3593    pub fn is_file_stale(&self, file: &Path) -> bool {
3594        let relative;
3595        let (file_mtimes, file_sizes, file_hashes, lookup) = if let Some(base) = &self.shared_base {
3596            relative = file
3597                .strip_prefix(&self.project_root)
3598                .unwrap_or(file)
3599                .to_path_buf();
3600            (
3601                &base.file_mtimes,
3602                &base.file_sizes,
3603                &base.file_hashes,
3604                relative.as_path(),
3605            )
3606        } else {
3607            (&self.file_mtimes, &self.file_sizes, &self.file_hashes, file)
3608        };
3609        let Some(stored_mtime) = file_mtimes.get(lookup) else {
3610            return true;
3611        };
3612        let Some(stored_size) = file_sizes.get(lookup) else {
3613            return true;
3614        };
3615        let Some(stored_hash) = file_hashes.get(lookup) else {
3616            return true;
3617        };
3618        let cached = FileFreshness {
3619            mtime: *stored_mtime,
3620            size: *stored_size,
3621            content_hash: *stored_hash,
3622        };
3623        match cache_freshness::verify_file_strict(file, &cached) {
3624            FreshnessVerdict::HotFresh => false,
3625            FreshnessVerdict::ContentFresh { .. } => false,
3626            FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
3627        }
3628    }
3629
3630    fn backfill_missing_file_sizes(&mut self) {
3631        if !self.any_missing_sizes {
3632            return;
3633        }
3634
3635        for path in self.file_mtimes.keys() {
3636            if self.file_sizes.contains_key(path) {
3637                continue;
3638            }
3639            if let Ok(metadata) = fs::metadata(path) {
3640                self.file_sizes.insert(path.clone(), metadata.len());
3641                if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
3642                    self.file_hashes.insert(path.clone(), hash);
3643                }
3644            }
3645        }
3646        self.any_missing_sizes = self
3647            .file_mtimes
3648            .keys()
3649            .any(|path| !self.file_sizes.contains_key(path));
3650    }
3651
3652    /// Remove entries for a specific file.
3653    pub fn remove_file(&mut self, file: &Path) {
3654        self.invalidate_file(file);
3655    }
3656
3657    pub fn invalidate_file(&mut self, file: &Path) {
3658        let file = file.to_path_buf();
3659        self.invalidate_files(std::slice::from_ref(&file));
3660    }
3661
3662    pub fn invalidate_files(&mut self, files: &[PathBuf]) {
3663        if files.is_empty() {
3664            return;
3665        }
3666        self.materialize_shared_base();
3667
3668        // Watchers may report a symlinked spelling while persisted metadata uses
3669        // the canonical spelling (or vice versa), so both keys must be removed.
3670        let mut invalidated = HashSet::with_capacity(files.len().saturating_mul(2));
3671        let mut metadata_keys = Vec::with_capacity(files.len().saturating_mul(2));
3672        for file in files {
3673            metadata_keys.push(file.clone());
3674            invalidated.insert(file.clone());
3675            let canonical = canonicalize_existing_or_deleted_path(file);
3676            if canonical != *file {
3677                metadata_keys.push(canonical.clone());
3678                invalidated.insert(canonical);
3679            }
3680        }
3681        self.remove_indexed_file_keys(&invalidated, &metadata_keys);
3682    }
3683
3684    #[cfg(test)]
3685    pub(crate) fn removal_retain_passes_for_test(&self) -> usize {
3686        self.removal_retain_passes
3687    }
3688
3689    #[cfg(test)]
3690    pub(crate) fn uses_shared_base_for_test(&self) -> bool {
3691        self.shared_base.is_some()
3692    }
3693
3694    /// Get the embedding dimension
3695    pub fn dimension(&self) -> usize {
3696        self.shared_base
3697            .as_ref()
3698            .map(|base| base.dimension)
3699            .unwrap_or(self.dimension)
3700    }
3701
3702    pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
3703        self.shared_base
3704            .as_ref()
3705            .and_then(|base| base.fingerprint.as_ref())
3706            .or(self.fingerprint.as_ref())
3707    }
3708
3709    pub fn backend_label(&self) -> Option<&str> {
3710        self.fingerprint().map(|f| f.backend.as_str())
3711    }
3712
3713    pub fn model_label(&self) -> Option<&str> {
3714        self.fingerprint().map(|f| f.model.as_str())
3715    }
3716
3717    pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
3718        self.materialize_shared_base();
3719        self.fingerprint = Some(fingerprint);
3720    }
3721
3722    /// Write the semantic index to disk using atomic temp+rename pattern.
3723    /// Empty indexes are persisted too so a completed rebuild cannot leave an
3724    /// older non-empty snapshot visible to the next process.
3725    pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) -> bool {
3726        if self.shared_base.is_some() {
3727            let mut private = self.clone();
3728            private.materialize_shared_base();
3729            return private.write_to_disk(storage_dir, project_key);
3730        }
3731        let dir = storage_dir.join("semantic").join(project_key);
3732        let data_path = dir.join("semantic.bin");
3733        let access = crate::root_cache::ArtifactAccess::for_root(&self.project_root);
3734        if !access.allows_write(project_key, &data_path) {
3735            return false;
3736        }
3737        if let Err(e) = fs::create_dir_all(&dir) {
3738            slog_warn!("failed to create semantic cache dir: {}", e);
3739            return false;
3740        }
3741        let tmp_path = dir.join(format!(
3742            "semantic.bin.tmp.{}.{}",
3743            std::process::id(),
3744            SystemTime::now()
3745                .duration_since(SystemTime::UNIX_EPOCH)
3746                .unwrap_or(Duration::ZERO)
3747                .as_nanos()
3748        ));
3749        let write_result = (|| -> io::Result<usize> {
3750            let file = fs::File::create(&tmp_path)?;
3751            let mut writer = BufWriter::new(file);
3752            let bytes_written = self.write_to_writer(&mut writer)?;
3753            writer.flush()?;
3754            writer.get_ref().sync_all()?;
3755            Ok(bytes_written)
3756        })();
3757        let bytes_written = match write_result {
3758            Ok(bytes_written) => bytes_written,
3759            Err(e) => {
3760                slog_warn!("failed to write semantic index: {}", e);
3761                let _ = fs::remove_file(&tmp_path);
3762                return false;
3763            }
3764        };
3765        if let Err(e) = crate::fs_lock::rename_over(&tmp_path, &data_path) {
3766            slog_warn!("failed to rename semantic index: {}", e);
3767            let _ = fs::remove_file(&tmp_path);
3768            return false;
3769        }
3770        slog_info!(
3771            "semantic index persisted: {} entries, {:.1} KB",
3772            self.entries.len(),
3773            bytes_written as f64 / 1024.0
3774        );
3775        true
3776    }
3777
3778    /// Read the semantic index from disk
3779    pub fn read_from_disk(
3780        storage_dir: &Path,
3781        project_key: &str,
3782        current_canonical_root: &Path,
3783        is_worktree_bridge: bool,
3784        expected_fingerprint: Option<&str>,
3785    ) -> Option<Self> {
3786        debug_assert!(current_canonical_root.is_absolute());
3787        let data_path = storage_dir
3788            .join("semantic")
3789            .join(project_key)
3790            .join("semantic.bin");
3791        let file = fs::File::open(&data_path).ok()?;
3792        let file_len = usize::try_from(file.metadata().ok()?.len()).ok()?;
3793        if file_len < HEADER_BYTES_V1 {
3794            slog_warn!(
3795                "corrupt semantic index (too small: {} bytes), removing",
3796                file_len
3797            );
3798            if !is_worktree_bridge {
3799                let _ = fs::remove_file(&data_path);
3800            }
3801            return None;
3802        }
3803
3804        let mut reader = BufReader::new(file);
3805        let mut version_buf = [0u8; 1];
3806        reader.read_exact(&mut version_buf).ok()?;
3807        let version = version_buf[0];
3808        if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
3809            slog_info!(
3810            "cached semantic index version {} is not compatible with {}, rebuilding without deleting the shared artifact",
3811            version,
3812            SEMANTIC_INDEX_VERSION_V7
3813        );
3814            return None;
3815        }
3816        match Self::from_reader_after_version(
3817            reader,
3818            version,
3819            current_canonical_root,
3820            Some(file_len),
3821            1,
3822        ) {
3823            Ok(index) => {
3824                if let Some(expected) = expected_fingerprint {
3825                    let matches = index
3826                        .fingerprint()
3827                        .map(|fingerprint| fingerprint.matches_expected(expected))
3828                        .unwrap_or(false);
3829                    if !matches {
3830                        log_fingerprint_mismatch(index.fingerprint(), expected);
3831                        return None;
3832                    }
3833                }
3834                slog_info!(
3835                    "loaded semantic index from disk: {} entries",
3836                    index.entries.len()
3837                );
3838                Some(index)
3839            }
3840            Err(e) => {
3841                slog_warn!("corrupt semantic index, rebuilding: {}", e);
3842                if !is_worktree_bridge {
3843                    let _ = fs::remove_file(&data_path);
3844                }
3845                None
3846            }
3847        }
3848    }
3849
3850    pub(crate) fn read_from_disk_borrow_tolerant(
3851        storage_dir: &Path,
3852        project_key: &str,
3853        current_canonical_root: &Path,
3854    ) -> Option<Self> {
3855        let load_started = Instant::now();
3856        let loaded = Self::read_from_disk_borrow_tolerant_inner(
3857            storage_dir,
3858            project_key,
3859            current_canonical_root,
3860        );
3861        let outcome = if loaded.is_some() { "ready" } else { "denied" };
3862        let build_id = crate::logging::in_flight_build_id(
3863            crate::logging::IndexPlane::Semantic,
3864            current_canonical_root,
3865        )
3866        .unwrap_or_else(crate::logging::mint_index_build_id);
3867        crate::logging::log_index_event(
3868            crate::logging::IndexEvent::new(
3869                crate::logging::IndexEventKind::ArtifactLoaded,
3870                crate::logging::IndexPlane::Semantic,
3871                build_id,
3872                current_canonical_root,
3873                project_key,
3874            )
3875            .field("outcome", outcome)
3876            .field("borrowed", "true")
3877            .field(
3878                "elapsed_ms",
3879                load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
3880            ),
3881        );
3882        crate::logging::note_tool_call_wait(
3883            crate::run_tool_call::WaitingOn::ArtifactLoad,
3884            None,
3885            load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
3886        );
3887        loaded
3888    }
3889
3890    fn read_from_disk_borrow_tolerant_inner(
3891        storage_dir: &Path,
3892        project_key: &str,
3893        current_canonical_root: &Path,
3894    ) -> Option<Self> {
3895        let data_path = storage_dir
3896            .join("semantic")
3897            .join(project_key)
3898            .join("semantic.bin");
3899        let (fingerprint, artifact_content_hash) = match borrowed_artifact_identity(&data_path) {
3900            Ok(identity) => identity,
3901            Err(error) => {
3902                slog_warn!(
3903                    "semantic shared-base identity unavailable ({}); loading a private borrowed copy",
3904                    error
3905                );
3906                return Self::read_from_disk(
3907                    storage_dir,
3908                    project_key,
3909                    current_canonical_root,
3910                    true,
3911                    None,
3912                );
3913            }
3914        };
3915        let key = SharedSemanticBaseKey {
3916            artifact_cache_key: project_key.to_string(),
3917            fingerprint,
3918            artifact_content_hash,
3919        };
3920
3921        {
3922            let mut registry = shared_semantic_bases()
3923                .lock()
3924                .unwrap_or_else(std::sync::PoisonError::into_inner);
3925            registry.retain(|_, base| base.strong_count() > 0);
3926            if let Some(base) = registry.get(&key).and_then(Weak::upgrade) {
3927                SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3928                return Some(Self::from_shared_base(
3929                    current_canonical_root.to_path_buf(),
3930                    base,
3931                ));
3932            }
3933            if registry.keys().any(|existing| {
3934                existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3935            }) {
3936                slog_warn!(
3937                    "semantic shared-base fingerprint or artifact hash changed for key {}; loading a private borrowed copy",
3938                    project_key
3939                );
3940                return Self::read_from_disk(
3941                    storage_dir,
3942                    project_key,
3943                    current_canonical_root,
3944                    true,
3945                    None,
3946                );
3947            }
3948        }
3949
3950        let private = Self::read_from_disk(
3951            storage_dir,
3952            project_key,
3953            current_canonical_root,
3954            true,
3955            Some(&key.fingerprint),
3956        )?;
3957        let Some(base) = private.clone().into_shared_base() else {
3958            slog_warn!(
3959                "semantic shared-base paths could not be normalized for key {}; loading a private borrowed copy",
3960                project_key
3961            );
3962            return Some(private);
3963        };
3964        let base = Arc::new(base);
3965
3966        let mut registry = shared_semantic_bases()
3967            .lock()
3968            .unwrap_or_else(std::sync::PoisonError::into_inner);
3969        registry.retain(|_, base| base.strong_count() > 0);
3970        if let Some(existing) = registry.get(&key).and_then(Weak::upgrade) {
3971            SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3972            return Some(Self::from_shared_base(
3973                current_canonical_root.to_path_buf(),
3974                existing,
3975            ));
3976        }
3977        if registry.keys().any(|existing| {
3978            existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3979        }) {
3980            slog_warn!(
3981                "semantic shared-base identity changed while loading key {}; retaining a private borrowed copy",
3982                project_key
3983            );
3984            return Some(private);
3985        }
3986        registry.insert(key, Arc::downgrade(&base));
3987        SHARED_SEMANTIC_BASE_LOADS.fetch_add(1, Ordering::Relaxed);
3988        Some(Self::from_shared_base(
3989            current_canonical_root.to_path_buf(),
3990            base,
3991        ))
3992    }
3993
3994    /// Serialize the index to bytes for disk persistence
3995    pub fn to_bytes(&self) -> Vec<u8> {
3996        if self.shared_base.is_some() {
3997            let mut private = self.clone();
3998            private.materialize_shared_base();
3999            return private.to_bytes();
4000        }
4001        let mut buf = Vec::new();
4002        self.write_to_writer(&mut buf)
4003            .expect("writing semantic index to Vec cannot fail");
4004        buf
4005    }
4006
4007    fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
4008        let mut bytes_written = 0usize;
4009        let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
4010            let encoded = fingerprint.as_string();
4011            if encoded.is_empty() {
4012                None
4013            } else {
4014                Some(encoded)
4015            }
4016        });
4017        let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
4018        let file_mtime_count = self
4019            .file_mtimes
4020            .iter()
4021            .filter(|(path, _)| cache_relative_path(&self.project_root, path).is_some())
4022            .count();
4023        let entry_count = self
4024            .entries
4025            .iter()
4026            .filter(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
4027            .count();
4028
4029        // Header: version(1) + dimension(4) + entry_count(4) + fingerprint_len(4) + fingerprint
4030        //
4031        // V7 is the single write format. Layout extends V6 with per-entry
4032        // qualified_name metadata while preserving the embedding fingerprint:
4033        //   - fingerprint is always represented (absent ⇒ fingerprint_len=0,
4034        //     no bytes follow). Uniform format simplifies the reader.
4035        //   - paths are relative to project_root.
4036        //   - file metadata stored as secs(u64) + subsec_nanos(u32) + size(u64) + blake3(32).
4037        //     Preserves full APFS/ext4/NTFS precision and catches mtime ties.
4038        //
4039        // V1/V2 remain readable for backward compatibility (see from_bytes).
4040        // V3/V4 load as compatible formats but are rejected on disk so snippets
4041        // and file sizes are rebuilt once. V6 remains accepted on disk and
4042        // yields qualified_name=None until the next V7 write.
4043        let version = SEMANTIC_INDEX_VERSION_V7;
4044        write_counted(writer, &[version], &mut bytes_written)?;
4045        write_counted(
4046            writer,
4047            &(self.dimension as u32).to_le_bytes(),
4048            &mut bytes_written,
4049        )?;
4050        write_counted(
4051            writer,
4052            &(entry_count as u32).to_le_bytes(),
4053            &mut bytes_written,
4054        )?;
4055        write_counted(
4056            writer,
4057            &(fp_bytes_ref.len() as u32).to_le_bytes(),
4058            &mut bytes_written,
4059        )?;
4060        write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
4061
4062        // File mtime table: count(4) + entries
4063        // V3 layout per entry: path_len(4) + path + secs(8) + subsec_nanos(4)
4064        write_counted(
4065            writer,
4066            &(file_mtime_count as u32).to_le_bytes(),
4067            &mut bytes_written,
4068        )?;
4069        for (path, mtime) in &self.file_mtimes {
4070            let Some(relative) = cache_relative_path(&self.project_root, path) else {
4071                continue;
4072            };
4073            let relative = relative.to_string_lossy();
4074            let path_bytes = relative.as_bytes();
4075            write_counted(
4076                writer,
4077                &(path_bytes.len() as u32).to_le_bytes(),
4078                &mut bytes_written,
4079            )?;
4080            write_counted(writer, path_bytes, &mut bytes_written)?;
4081            let duration = mtime
4082                .duration_since(SystemTime::UNIX_EPOCH)
4083                .unwrap_or_default();
4084            write_counted(
4085                writer,
4086                &duration.as_secs().to_le_bytes(),
4087                &mut bytes_written,
4088            )?;
4089            write_counted(
4090                writer,
4091                &duration.subsec_nanos().to_le_bytes(),
4092                &mut bytes_written,
4093            )?;
4094            let size = self.file_sizes.get(path).copied().unwrap_or_default();
4095            write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
4096            let hash = self
4097                .file_hashes
4098                .get(path)
4099                .copied()
4100                .unwrap_or_else(cache_freshness::zero_hash);
4101            write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
4102        }
4103
4104        // Entries: each is metadata + vector
4105        for entry in &self.entries {
4106            let Some(relative) = cache_relative_path(&self.project_root, &entry.chunk.file) else {
4107                continue;
4108            };
4109            let c = &entry.chunk;
4110
4111            // File path
4112            let relative = relative.to_string_lossy();
4113            let file_bytes = relative.as_bytes();
4114            write_counted(
4115                writer,
4116                &(file_bytes.len() as u32).to_le_bytes(),
4117                &mut bytes_written,
4118            )?;
4119            write_counted(writer, file_bytes, &mut bytes_written)?;
4120
4121            // Name
4122            let name_bytes = c.name.as_bytes();
4123            write_counted(
4124                writer,
4125                &(name_bytes.len() as u32).to_le_bytes(),
4126                &mut bytes_written,
4127            )?;
4128            write_counted(writer, name_bytes, &mut bytes_written)?;
4129
4130            // Qualified name (V7 metadata; absent is encoded as length 0)
4131            let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
4132            write_counted(
4133                writer,
4134                &(qualified_name_bytes.len() as u32).to_le_bytes(),
4135                &mut bytes_written,
4136            )?;
4137            write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
4138
4139            // Kind (1 byte)
4140            write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
4141
4142            // Lines + exported
4143            write_counted(
4144                writer,
4145                &(c.start_line as u32).to_le_bytes(),
4146                &mut bytes_written,
4147            )?;
4148            write_counted(
4149                writer,
4150                &(c.end_line as u32).to_le_bytes(),
4151                &mut bytes_written,
4152            )?;
4153            write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
4154
4155            // Snippet
4156            let snippet_bytes = c.snippet.as_bytes();
4157            write_counted(
4158                writer,
4159                &(snippet_bytes.len() as u32).to_le_bytes(),
4160                &mut bytes_written,
4161            )?;
4162            write_counted(writer, snippet_bytes, &mut bytes_written)?;
4163
4164            // Embed text
4165            let embed_bytes = c.embed_text.as_bytes();
4166            write_counted(
4167                writer,
4168                &(embed_bytes.len() as u32).to_le_bytes(),
4169                &mut bytes_written,
4170            )?;
4171            write_counted(writer, embed_bytes, &mut bytes_written)?;
4172
4173            // Vector (f32 array)
4174            for &val in &entry.vector {
4175                write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
4176            }
4177        }
4178
4179        Ok(bytes_written)
4180    }
4181
4182    /// Deserialize the index from bytes
4183    pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
4184        debug_assert!(current_canonical_root.is_absolute());
4185        if data.len() < HEADER_BYTES_V1 {
4186            return Err("data too short".to_string());
4187        }
4188
4189        Self::from_reader_after_version(
4190            Cursor::new(&data[1..]),
4191            data[0],
4192            current_canonical_root,
4193            Some(data.len()),
4194            1,
4195        )
4196    }
4197
4198    fn from_reader_after_version<R: Read>(
4199        reader: R,
4200        version: u8,
4201        current_canonical_root: &Path,
4202        total_len: Option<usize>,
4203        bytes_read: usize,
4204    ) -> Result<Self, String> {
4205        debug_assert!(current_canonical_root.is_absolute());
4206        let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
4207
4208        if version != SEMANTIC_INDEX_VERSION_V1
4209            && version != SEMANTIC_INDEX_VERSION_V2
4210            && version != SEMANTIC_INDEX_VERSION_V3
4211            && version != SEMANTIC_INDEX_VERSION_V4
4212            && version != SEMANTIC_INDEX_VERSION_V5
4213            && version != SEMANTIC_INDEX_VERSION_V6
4214            && version != SEMANTIC_INDEX_VERSION_V7
4215        {
4216            return Err(format!("unsupported version: {}", version));
4217        }
4218        // V2 and newer share the same header layout (V3/V4/V5 only differ from
4219        // V2 in the per-mtime entry layout): version(1) + dimension(4) +
4220        // entry_count(4) + fingerprint_len(4) + fingerprint bytes.
4221        if (version == SEMANTIC_INDEX_VERSION_V2
4222            || version == SEMANTIC_INDEX_VERSION_V3
4223            || version == SEMANTIC_INDEX_VERSION_V4
4224            || version == SEMANTIC_INDEX_VERSION_V5
4225            || version == SEMANTIC_INDEX_VERSION_V6
4226            || version == SEMANTIC_INDEX_VERSION_V7)
4227            && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
4228        {
4229            return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
4230        }
4231
4232        let dimension = read_u32_stream(&mut reader)? as usize;
4233        let entry_count = read_u32_stream(&mut reader)? as usize;
4234        validate_embedding_dimension(dimension)?;
4235        if entry_count > MAX_ENTRIES {
4236            return Err(format!("too many semantic index entries: {}", entry_count));
4237        }
4238
4239        // Fingerprint handling:
4240        //   - V1: no fingerprint field at all.
4241        //   - V2: fingerprint_len + fingerprint bytes; always present (writer
4242        //     only emitted V2 when fingerprint was Some).
4243        //   - V3+: fingerprint_len always present; fingerprint_len==0 ⇒ None.
4244        let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
4245            || version == SEMANTIC_INDEX_VERSION_V3
4246            || version == SEMANTIC_INDEX_VERSION_V4
4247            || version == SEMANTIC_INDEX_VERSION_V5
4248            || version == SEMANTIC_INDEX_VERSION_V6
4249            || version == SEMANTIC_INDEX_VERSION_V7;
4250        let fingerprint = if has_fingerprint_field {
4251            let fingerprint_len = read_u32_stream(&mut reader)? as usize;
4252            if total_len
4253                .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
4254            {
4255                return Err("unexpected end of data reading fingerprint".to_string());
4256            }
4257            if fingerprint_len == 0 {
4258                None
4259            } else {
4260                let mut raw = vec![0u8; fingerprint_len];
4261                read_exact_stream(
4262                    &mut reader,
4263                    &mut raw,
4264                    "unexpected end of data reading fingerprint",
4265                )?;
4266                let raw = String::from_utf8_lossy(&raw).to_string();
4267                Some(
4268                    serde_json::from_str::<SemanticIndexFingerprint>(&raw)
4269                        .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
4270                )
4271            }
4272        } else {
4273            None
4274        };
4275
4276        // File mtimes
4277        let mtime_count = read_u32_stream(&mut reader)? as usize;
4278        if mtime_count > MAX_ENTRIES {
4279            return Err(format!("too many semantic file mtimes: {}", mtime_count));
4280        }
4281
4282        let vector_bytes = entry_count
4283            .checked_mul(dimension)
4284            .and_then(|count| count.checked_mul(F32_BYTES))
4285            .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
4286        if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
4287            return Err("semantic index vectors exceed available data".to_string());
4288        }
4289
4290        let mut file_mtimes = HashMap::with_capacity(mtime_count);
4291        let mut file_sizes = HashMap::with_capacity(mtime_count);
4292        let mut file_hashes = HashMap::with_capacity(mtime_count);
4293        for _ in 0..mtime_count {
4294            let path = read_string_stream(&mut reader, total_len)?;
4295            let secs = read_u64_stream(&mut reader)?;
4296            // V3+ persists subsec_nanos alongside secs so staleness checks
4297            // survive restart round-trips. V1/V2 load with 0 nanos, which
4298            // causes one rebuild on upgrade (they never matched live APFS
4299            // mtimes anyway — the bug v0.15.2 fixes). After that rebuild,
4300            // the cache is persisted as V3 and stabilises.
4301            let nanos = if version == SEMANTIC_INDEX_VERSION_V3
4302                || version == SEMANTIC_INDEX_VERSION_V4
4303                || version == SEMANTIC_INDEX_VERSION_V5
4304                || version == SEMANTIC_INDEX_VERSION_V6
4305                || version == SEMANTIC_INDEX_VERSION_V7
4306            {
4307                read_u32_stream(&mut reader)?
4308            } else {
4309                0
4310            };
4311            let size = if version == SEMANTIC_INDEX_VERSION_V5
4312                || version == SEMANTIC_INDEX_VERSION_V6
4313                || version == SEMANTIC_INDEX_VERSION_V7
4314            {
4315                read_u64_stream(&mut reader)?
4316            } else {
4317                0
4318            };
4319            let content_hash =
4320                if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
4321                    let mut hash_bytes = [0u8; 32];
4322                    read_exact_stream(
4323                        &mut reader,
4324                        &mut hash_bytes,
4325                        "unexpected end of data reading content hash",
4326                    )?;
4327                    blake3::Hash::from_bytes(hash_bytes)
4328                } else {
4329                    cache_freshness::zero_hash()
4330                };
4331            // Hardening against corrupt / maliciously crafted cache files
4332            // (v0.15.2). `Duration::new(secs, nanos)` can panic when the
4333            // nanosecond carry overflows the second counter, and
4334            // `SystemTime + Duration` can panic on carry past the platform's
4335            // upper bound. Explicit validation keeps a corrupted semantic.bin
4336            // from taking down the whole aft process.
4337            if nanos >= 1_000_000_000 {
4338                return Err(format!(
4339                    "invalid semantic mtime: nanos {} >= 1_000_000_000",
4340                    nanos
4341                ));
4342            }
4343            let duration = std::time::Duration::new(secs, nanos);
4344            let mtime = SystemTime::UNIX_EPOCH
4345                .checked_add(duration)
4346                .ok_or_else(|| {
4347                    format!(
4348                        "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
4349                        secs, nanos
4350                    )
4351                })?;
4352            let path = if version == SEMANTIC_INDEX_VERSION_V6
4353                || version == SEMANTIC_INDEX_VERSION_V7
4354            {
4355                cached_path_under_root(current_canonical_root, &PathBuf::from(path))
4356                    .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
4357            } else {
4358                PathBuf::from(path)
4359            };
4360            file_mtimes.insert(path.clone(), mtime);
4361            file_sizes.insert(path.clone(), size);
4362            file_hashes.insert(path, content_hash);
4363        }
4364
4365        // Entries
4366        let mut entries = Vec::with_capacity(entry_count);
4367        for _ in 0..entry_count {
4368            let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
4369            let file = if version == SEMANTIC_INDEX_VERSION_V6
4370                || version == SEMANTIC_INDEX_VERSION_V7
4371            {
4372                cached_path_under_root(current_canonical_root, &raw_file)
4373                    .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
4374            } else {
4375                raw_file
4376            };
4377            let name = read_string_stream(&mut reader, total_len)?;
4378            let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
4379                let qualified_name = read_string_stream(&mut reader, total_len)?;
4380                if qualified_name.is_empty() {
4381                    None
4382                } else {
4383                    Some(qualified_name)
4384                }
4385            } else {
4386                None
4387            };
4388
4389            let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
4390
4391            let start_line = read_u32_stream(&mut reader)?;
4392            let end_line = read_u32_stream(&mut reader)?;
4393
4394            let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
4395
4396            let snippet = read_string_stream(&mut reader, total_len)?;
4397            let embed_text = read_string_stream(&mut reader, total_len)?;
4398
4399            // Vector
4400            let vec_bytes = dimension
4401                .checked_mul(F32_BYTES)
4402                .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
4403            if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
4404                return Err("unexpected end of data reading vector".to_string());
4405            }
4406            let mut vector = Vec::with_capacity(dimension);
4407            for _ in 0..dimension {
4408                let mut bytes = [0u8; F32_BYTES];
4409                read_exact_stream(
4410                    &mut reader,
4411                    &mut bytes,
4412                    "unexpected end of data reading vector",
4413                )?;
4414                vector.push(f32::from_le_bytes(bytes));
4415            }
4416
4417            entries.push(EmbeddingEntry::new(
4418                SemanticChunk {
4419                    file,
4420                    name,
4421                    qualified_name,
4422                    kind,
4423                    start_line,
4424                    end_line,
4425                    exported,
4426                    embed_text,
4427                    snippet,
4428                },
4429                vector,
4430            ));
4431        }
4432
4433        if entries.len() != entry_count {
4434            return Err(format!(
4435                "semantic cache entry count drift: header={} decoded={}",
4436                entry_count,
4437                entries.len()
4438            ));
4439        }
4440        for entry in &entries {
4441            if !file_mtimes.contains_key(&entry.chunk.file) {
4442                return Err(format!(
4443                    "semantic cache metadata missing for entry file {}",
4444                    entry.chunk.file.display()
4445                ));
4446            }
4447        }
4448
4449        let any_missing_sizes = file_mtimes
4450            .keys()
4451            .any(|path| !file_sizes.contains_key(path));
4452        Ok(Self {
4453            entries,
4454            file_mtimes,
4455            file_sizes,
4456            any_missing_sizes,
4457            file_hashes,
4458            dimension,
4459            fingerprint,
4460            project_root: current_canonical_root.to_path_buf(),
4461            deferred_files: HashSet::new(),
4462            shared_base: None,
4463            #[cfg(test)]
4464            removal_retain_passes: 0,
4465        })
4466    }
4467}
4468
4469fn write_counted<W: Write>(
4470    writer: &mut W,
4471    bytes: &[u8],
4472    bytes_written: &mut usize,
4473) -> io::Result<()> {
4474    writer.write_all(bytes)?;
4475    *bytes_written = bytes_written.saturating_add(bytes.len());
4476    Ok(())
4477}
4478
4479struct CountingReader<R> {
4480    inner: R,
4481    bytes_read: usize,
4482}
4483
4484impl<R> CountingReader<R> {
4485    fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
4486        Self { inner, bytes_read }
4487    }
4488
4489    fn bytes_read(&self) -> usize {
4490        self.bytes_read
4491    }
4492}
4493
4494impl<R: Read> Read for CountingReader<R> {
4495    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
4496        let read = self.inner.read(buf)?;
4497        self.bytes_read = self.bytes_read.saturating_add(read);
4498        Ok(read)
4499    }
4500}
4501
4502fn read_exact_stream<R: Read>(
4503    reader: &mut CountingReader<R>,
4504    buf: &mut [u8],
4505    eof_message: &'static str,
4506) -> Result<(), String> {
4507    reader.read_exact(buf).map_err(|error| {
4508        if error.kind() == io::ErrorKind::UnexpectedEof {
4509            eof_message.to_string()
4510        } else {
4511            format!("{eof_message}: {error}")
4512        }
4513    })
4514}
4515
4516fn read_u8_stream<R: Read>(
4517    reader: &mut CountingReader<R>,
4518    eof_message: &'static str,
4519) -> Result<u8, String> {
4520    let mut bytes = [0u8; 1];
4521    read_exact_stream(reader, &mut bytes, eof_message)?;
4522    Ok(bytes[0])
4523}
4524
4525fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
4526    let mut bytes = [0u8; 4];
4527    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
4528    Ok(u32::from_le_bytes(bytes))
4529}
4530
4531fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
4532    let mut bytes = [0u8; 8];
4533    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
4534    Ok(u64::from_le_bytes(bytes))
4535}
4536
4537fn read_string_stream<R: Read>(
4538    reader: &mut CountingReader<R>,
4539    total_len: Option<usize>,
4540) -> Result<String, String> {
4541    let len = read_u32_stream(reader)? as usize;
4542    if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
4543        return Err("unexpected end of data reading string".to_string());
4544    }
4545    let mut bytes = vec![0u8; len];
4546    read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
4547    Ok(String::from_utf8_lossy(&bytes).to_string())
4548}
4549
4550struct SourceLineCache<'a> {
4551    lines: Vec<&'a str>,
4552    line_starts: Vec<usize>,
4553}
4554
4555impl<'a> SourceLineCache<'a> {
4556    fn new(source: &'a str) -> Self {
4557        let lines: Vec<&'a str> = source.lines().collect();
4558        let mut line_starts = Vec::with_capacity(lines.len());
4559        let bytes = source.as_bytes();
4560        let mut offset = 0usize;
4561        for line in &lines {
4562            line_starts.push(offset);
4563            offset += line.len();
4564            if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
4565                offset += 2;
4566            } else if bytes.get(offset) == Some(&b'\n') {
4567                offset += 1;
4568            }
4569        }
4570        Self { lines, line_starts }
4571    }
4572
4573    fn len(&self) -> usize {
4574        debug_assert_eq!(self.lines.len(), self.line_starts.len());
4575        self.line_starts.len()
4576    }
4577}
4578
4579/// Build enriched embedding text from a symbol with cAST-style context
4580fn build_embed_text_with_lines(
4581    symbol: &Symbol,
4582    line_cache: &SourceLineCache<'_>,
4583    file: &Path,
4584    project_root: &Path,
4585) -> String {
4586    let relative = file
4587        .strip_prefix(project_root)
4588        .unwrap_or(file)
4589        .to_string_lossy();
4590
4591    let kind_label = match &symbol.kind {
4592        SymbolKind::Function => "function",
4593        SymbolKind::Class => "class",
4594        SymbolKind::Method => "method",
4595        SymbolKind::Struct => "struct",
4596        SymbolKind::Interface => "interface",
4597        SymbolKind::Enum => "enum",
4598        SymbolKind::TypeAlias => "type",
4599        SymbolKind::Variable => "variable",
4600        SymbolKind::Heading => "heading",
4601        SymbolKind::FileSummary => "file-summary",
4602    };
4603
4604    // Build: "file:relative/path kind:function name:validateAuth signature:fn validateAuth(token: &str) -> bool"
4605    let name = &symbol.name;
4606    let mut text = format!(
4607        "name:{name} file:{} kind:{} name:{name}",
4608        relative, kind_label
4609    );
4610
4611    if let Some(sig) = &symbol.signature {
4612        // Cap the signature: structured parsers (e.g. YAML/Kubernetes) pack
4613        // entire inline scripts (CronJob/Job `command:` bodies, multi-KB) into
4614        // the signature. Appending it unbounded produces a single embed_text
4615        // that overflows the embedding backend's physical batch (e.g. a
4616        // llama.cpp server's 512-token cap), aborting the whole index build
4617        // and silently degrading every search to lexical. 400 chars keeps the
4618        // identifying head of the signature without blowing the budget.
4619        text.push_str(&format!(" signature:{}", truncate_chars(sig, 400)));
4620    }
4621
4622    // Add body snippet (first ~300 chars of symbol body)
4623    let start = (symbol.range.start_line as usize).min(line_cache.len());
4624    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
4625    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
4626    if start < end {
4627        let body: String = line_cache.lines[start..end]
4628            .iter()
4629            .take(15) // max 15 lines
4630            .copied()
4631            .collect::<Vec<&str>>()
4632            .join("\n");
4633        let snippet = if body.len() > 300 {
4634            format!("{}...", &body[..body.floor_char_boundary(300)])
4635        } else {
4636            body
4637        };
4638        text.push_str(&format!(" body:{}", snippet));
4639    }
4640
4641    // Final defense-in-depth clamp: no single embed_text may exceed the
4642    // backend's per-input budget regardless of which field grew. Most
4643    // backends cap a physical batch around 512 tokens; ~1600 chars stays
4644    // comfortably under that for typical English/code (≈4 chars/token).
4645    truncate_chars(&text, MAX_EMBED_TEXT_CHARS)
4646}
4647
4648#[cfg(test)]
4649fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
4650    let line_cache = SourceLineCache::new(source);
4651    build_embed_text_with_lines(symbol, &line_cache, file, project_root)
4652}
4653
4654/// Upper bound on characters in a single chunk's `embed_text`. Keeps any one
4655/// input below typical embedding-backend physical batch limits (~512 tokens)
4656/// so an oversized symbol cannot abort the whole index build.
4657const MAX_EMBED_TEXT_CHARS: usize = 1600;
4658
4659fn truncate_chars(value: &str, max_chars: usize) -> String {
4660    value.chars().take(max_chars).collect()
4661}
4662
4663fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
4664    let Some((start, first)) = line_cache
4665        .lines
4666        .iter()
4667        .enumerate()
4668        .find(|(_, line)| !line.trim().is_empty())
4669    else {
4670        return String::new();
4671    };
4672
4673    let trimmed = first.trim_start();
4674    if trimmed.starts_with("/**") {
4675        let mut comment = Vec::new();
4676        for line in line_cache.lines.iter().skip(start) {
4677            comment.push(*line);
4678            if line.contains("*/") {
4679                break;
4680            }
4681        }
4682        return truncate_chars(&comment.join("\n"), 200);
4683    }
4684
4685    if trimmed.starts_with("///") || trimmed.starts_with("//!") {
4686        let comment = line_cache
4687            .lines
4688            .iter()
4689            .skip(start)
4690            .take_while(|line| {
4691                let trimmed = line.trim_start();
4692                trimmed.starts_with("///") || trimmed.starts_with("//!")
4693            })
4694            .copied()
4695            .collect::<Vec<_>>()
4696            .join("\n");
4697        return truncate_chars(&comment, 200);
4698    }
4699
4700    String::new()
4701}
4702
4703pub fn build_file_summary_chunk(
4704    file: &Path,
4705    project_root: &Path,
4706    source: &str,
4707    top_exports: &[&str],
4708    top_export_signatures: &[Option<&str>],
4709) -> SemanticChunk {
4710    let line_cache = SourceLineCache::new(source);
4711    build_file_summary_chunk_with_lines(
4712        file,
4713        project_root,
4714        &line_cache,
4715        top_exports,
4716        top_export_signatures,
4717    )
4718}
4719
4720fn build_file_summary_chunk_with_lines(
4721    file: &Path,
4722    project_root: &Path,
4723    line_cache: &SourceLineCache<'_>,
4724    top_exports: &[&str],
4725    top_export_signatures: &[Option<&str>],
4726) -> SemanticChunk {
4727    let relative = file.strip_prefix(project_root).unwrap_or(file);
4728    let rel_path = relative.to_string_lossy();
4729    let parent_dir = relative
4730        .parent()
4731        .map(|parent| parent.to_string_lossy().to_string())
4732        .unwrap_or_default();
4733    let name = file
4734        .file_stem()
4735        .map(|stem| stem.to_string_lossy().to_string())
4736        .unwrap_or_default();
4737    let doc = first_leading_doc_comment(line_cache);
4738    let exports = top_exports
4739        .iter()
4740        .take(5)
4741        .copied()
4742        .collect::<Vec<_>>()
4743        .join(",");
4744    let snippet = if doc.is_empty() {
4745        top_export_signatures
4746            .first()
4747            .and_then(|signature| signature.as_deref())
4748            .map(|signature| truncate_chars(signature, 200))
4749            .unwrap_or_default()
4750    } else {
4751        doc.clone()
4752    };
4753
4754    SemanticChunk {
4755        file: file.to_path_buf(),
4756        name,
4757        qualified_name: None,
4758        kind: SymbolKind::FileSummary,
4759        start_line: 0,
4760        end_line: 0,
4761        exported: false,
4762        embed_text: truncate_chars(
4763            &format!(
4764                "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
4765                file.file_stem()
4766                    .map(|stem| stem.to_string_lossy().to_string())
4767                    .unwrap_or_default()
4768            ),
4769            MAX_EMBED_TEXT_CHARS,
4770        ),
4771        snippet,
4772    }
4773}
4774
4775pub fn is_semantic_indexed_extension(path: &Path) -> bool {
4776    if path.file_name().and_then(|name| name.to_str()) == Some("Jenkinsfile") {
4777        return true;
4778    }
4779
4780    matches!(
4781        path.extension().and_then(|extension| extension.to_str()),
4782        Some(
4783            "ts" | "tsx"
4784                | "js"
4785                | "jsx"
4786                | "py"
4787                | "rs"
4788                | "go"
4789                | "c"
4790                | "h"
4791                | "cc"
4792                | "cpp"
4793                | "cxx"
4794                | "hpp"
4795                | "hh"
4796                | "zig"
4797                | "cs"
4798                | "sh"
4799                | "bash"
4800                | "zsh"
4801                | "inc"
4802                | "php"
4803                | "sol"
4804                | "scss"
4805                | "vue"
4806                | "yaml"
4807                | "yml"
4808                | "pas"
4809                | "pp"
4810                | "dpr"
4811                | "dpk"
4812                | "lpr"
4813                | "java"
4814                | "kt"
4815                | "kts"
4816                | "rb"
4817                | "swift"
4818                | "scala"
4819                | "sc"
4820                | "lua"
4821                | "pl"
4822                | "pm"
4823                | "t"
4824                | "r"
4825                | "R"
4826                | "groovy"
4827                | "gvy"
4828                | "gy"
4829                | "gsh"
4830                | "gradle"
4831                | "m"
4832                | "mm",
4833        )
4834    )
4835}
4836
4837fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
4838    if let Ok(canonical) = fs::canonicalize(path) {
4839        return canonical;
4840    }
4841
4842    let Some(parent) = path.parent() else {
4843        return path.to_path_buf();
4844    };
4845    let Some(file_name) = path.file_name() else {
4846        return path.to_path_buf();
4847    };
4848
4849    fs::canonicalize(parent)
4850        .map(|canonical_parent| canonical_parent.join(file_name))
4851        .unwrap_or_else(|_| path.to_path_buf())
4852}
4853
4854/// Files larger than this are skipped for semantic chunking. The read +
4855/// tree-sitter parse is transiently O(file size) (tree-sitter can use several×
4856/// the source bytes), and `par_iter` collection parses many files at once, so an
4857/// unbounded read here is an OOM vector on a repo with a few multi-MB generated/
4858/// vendored/minified files. A file this large yields almost no useful embedding
4859/// anyway (each chunk's embed_text is clamped to MAX_EMBED_TEXT_CHARS), so we
4860/// track it (0 chunks) instead of reading it — freshness then skips it on later
4861/// refreshes. 4 MiB keeps essentially all hand-written source while capping the
4862/// pathological tail.
4863const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
4864
4865fn collect_semantic_file(
4866    project_root: &Path,
4867    file: &Path,
4868    phases: &mut SemanticCollectPhaseTimings,
4869) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
4870    let read_hash_started = Instant::now();
4871    let read_result = (|| {
4872        let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
4873        if !metadata.is_file() {
4874            return Err("not a regular file".to_string());
4875        }
4876        let mtime = metadata.modified().map_err(|error| error.to_string())?;
4877        let size = metadata.len();
4878
4879        if !is_semantic_indexed_extension(file) {
4880            return Err("unsupported file extension".to_string());
4881        }
4882        let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4883
4884        let mut indexed_metadata = IndexedFileMetadata {
4885            mtime,
4886            size,
4887            content_hash: cache_freshness::zero_hash(),
4888        };
4889
4890        // OOM backstop: skip oversized files before the read + parse (tracked with
4891        // zero chunks by the caller, so freshness won't re-read them every refresh).
4892        if size > MAX_SEMANTIC_FILE_BYTES {
4893            return Ok((indexed_metadata, lang, None));
4894        }
4895
4896        let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4897        indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
4898            cache_freshness::hash_bytes(source.as_bytes())
4899        } else {
4900            cache_freshness::zero_hash()
4901        };
4902        Ok((indexed_metadata, lang, Some(source)))
4903    })();
4904    phases.read_hash += read_hash_started.elapsed();
4905    let (indexed_metadata, lang, source) = read_result?;
4906    let Some(source) = source else {
4907        return Ok((indexed_metadata, Vec::new()));
4908    };
4909
4910    let chunks = collect_file_chunks_from_source_timed(project_root, file, lang, &source, phases)?;
4911    Ok((indexed_metadata, chunks))
4912}
4913
4914#[cfg(test)]
4915fn collect_file_chunks(project_root: &Path, file: &Path) -> Result<Vec<SemanticChunk>, String> {
4916    if !is_semantic_indexed_extension(file) {
4917        return Err("unsupported file extension".to_string());
4918    }
4919    let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4920    // OOM backstop: skip oversized files before the read + parse (tracked with
4921    // zero chunks by the caller, so freshness won't re-read them every refresh).
4922    if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
4923        return Ok(Vec::new());
4924    }
4925    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4926    collect_file_chunks_from_source(project_root, file, lang, &source)
4927}
4928
4929#[cfg(test)]
4930fn collect_file_chunks_from_source(
4931    project_root: &Path,
4932    file: &Path,
4933    lang: crate::parser::LangId,
4934    source: &str,
4935) -> Result<Vec<SemanticChunk>, String> {
4936    collect_file_chunks_from_source_timed(
4937        project_root,
4938        file,
4939        lang,
4940        source,
4941        &mut SemanticCollectPhaseTimings::default(),
4942    )
4943}
4944
4945fn collect_file_chunks_from_source_timed(
4946    project_root: &Path,
4947    file: &Path,
4948    lang: crate::parser::LangId,
4949    source: &str,
4950    phases: &mut SemanticCollectPhaseTimings,
4951) -> Result<Vec<SemanticChunk>, String> {
4952    let parse_started = Instant::now();
4953    let tree_result =
4954        parse_source_with_cached_parser(file, source, lang).map_err(|error| error.to_string());
4955    phases.parse += parse_started.elapsed();
4956    let tree = tree_result?;
4957
4958    let extract_started = Instant::now();
4959    let symbols_result =
4960        extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string());
4961    phases.extract += extract_started.elapsed();
4962    let symbols = symbols_result?;
4963
4964    let build_started = Instant::now();
4965    let chunks = symbols_to_chunks(file, &symbols, source, project_root);
4966    phases.build += build_started.elapsed();
4967    Ok(chunks)
4968}
4969
4970/// Build a display snippet from a symbol's source
4971fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
4972    let start = (symbol.range.start_line as usize).min(line_cache.len());
4973    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
4974    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
4975    if start < end {
4976        let snippet_lines: Vec<&str> = line_cache.lines[start..end]
4977            .iter()
4978            .take(5)
4979            .copied()
4980            .collect();
4981        let mut snippet = snippet_lines.join("\n");
4982        if end - start > 5 {
4983            snippet.push_str("\n  ...");
4984        }
4985        if snippet.len() > 300 {
4986            snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
4987        }
4988        snippet
4989    } else {
4990        String::new()
4991    }
4992}
4993
4994#[cfg(test)]
4995fn build_snippet(symbol: &Symbol, source: &str) -> String {
4996    let line_cache = SourceLineCache::new(source);
4997    build_snippet_with_lines(symbol, &line_cache)
4998}
4999
5000fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
5001    let mut parts = symbol
5002        .scope_chain
5003        .iter()
5004        .filter(|part| !part.is_empty())
5005        .cloned()
5006        .collect::<Vec<_>>();
5007    if !symbol.name.is_empty() {
5008        parts.push(symbol.name.clone());
5009    }
5010    (!parts.is_empty()).then(|| parts.join("."))
5011}
5012
5013/// Convert symbols to semantic chunks with enriched context
5014fn symbols_to_chunks(
5015    file: &Path,
5016    symbols: &[Symbol],
5017    source: &str,
5018    project_root: &Path,
5019) -> Vec<SemanticChunk> {
5020    let line_cache = SourceLineCache::new(source);
5021    let mut chunks = Vec::new();
5022    let top_exports_with_signatures = symbols
5023        .iter()
5024        .filter(|symbol| {
5025            symbol.exported
5026                && symbol.parent.is_none()
5027                && !matches!(symbol.kind, SymbolKind::Heading)
5028        })
5029        .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
5030        .collect::<Vec<_>>();
5031
5032    let has_only_headings = !symbols.is_empty()
5033        && symbols
5034            .iter()
5035            .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
5036    if top_exports_with_signatures.len() <= 2 && !has_only_headings {
5037        let top_exports = top_exports_with_signatures
5038            .iter()
5039            .map(|(name, _)| *name)
5040            .collect::<Vec<_>>();
5041        let top_export_signatures = top_exports_with_signatures
5042            .iter()
5043            .map(|(_, signature)| *signature)
5044            .collect::<Vec<_>>();
5045        chunks.push(build_file_summary_chunk_with_lines(
5046            file,
5047            project_root,
5048            &line_cache,
5049            &top_exports,
5050            &top_export_signatures,
5051        ));
5052    }
5053
5054    for symbol in symbols {
5055        // Skip Markdown / HTML heading chunks: empirically they dominate result
5056        // lists even for code-shaped queries because heading prose embeds well.
5057        // Agents querying for code lose the actual matches under doc noise.
5058        // README/docs queries are still served by grep on the same files.
5059        if matches!(symbol.kind, SymbolKind::Heading) {
5060            continue;
5061        }
5062
5063        // Skip very small symbols (single-line variables, etc.)
5064        let line_count = symbol
5065            .range
5066            .end_line
5067            .saturating_sub(symbol.range.start_line)
5068            + 1;
5069        if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
5070            continue;
5071        }
5072
5073        let embed_text = build_embed_text_with_lines(symbol, &line_cache, file, project_root);
5074        let snippet = build_snippet_with_lines(symbol, &line_cache);
5075
5076        chunks.push(SemanticChunk {
5077            file: file.to_path_buf(),
5078            name: symbol.name.clone(),
5079            qualified_name: qualified_name_for_symbol(symbol),
5080            kind: symbol.kind.clone(),
5081            start_line: symbol.range.start_line,
5082            end_line: symbol.range.end_line,
5083            exported: symbol.exported,
5084            embed_text,
5085            snippet,
5086        });
5087
5088        // Note: Nested symbols are handled separately by the outline system
5089        // Each symbol is indexed individually
5090    }
5091
5092    chunks
5093}
5094
5095fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
5096    b.0.partial_cmp(&a.0)
5097        .unwrap_or(std::cmp::Ordering::Equal)
5098        .then_with(|| a.1.cmp(&b.1))
5099}
5100
5101/// Compute an embedding's L2 norm for its in-memory search cache.
5102fn vector_norm(vector: &[f32]) -> f32 {
5103    vector.iter().map(|value| value * value).sum::<f32>().sqrt()
5104}
5105
5106fn dot_product(a: &[f32], b: &[f32]) -> f32 {
5107    a.iter().zip(b).map(|(a, b)| a * b).sum::<f32>()
5108}
5109
5110/// Cosine similarity reference retained for focused unit tests.
5111#[cfg(test)]
5112fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
5113    if a.len() != b.len() {
5114        return 0.0;
5115    }
5116
5117    let mut dot = 0.0f32;
5118    let mut norm_a = 0.0f32;
5119    let mut norm_b = 0.0f32;
5120
5121    for i in 0..a.len() {
5122        dot += a[i] * b[i];
5123        norm_a += a[i] * a[i];
5124        norm_b += b[i] * b[i];
5125    }
5126
5127    let denom = norm_a.sqrt() * norm_b.sqrt();
5128    if denom == 0.0 {
5129        0.0
5130    } else {
5131        dot / denom
5132    }
5133}
5134
5135// Serialization helpers
5136fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
5137    match kind {
5138        SymbolKind::Function => 0,
5139        SymbolKind::Class => 1,
5140        SymbolKind::Method => 2,
5141        SymbolKind::Struct => 3,
5142        SymbolKind::Interface => 4,
5143        SymbolKind::Enum => 5,
5144        SymbolKind::TypeAlias => 6,
5145        SymbolKind::Variable => 7,
5146        SymbolKind::Heading => 8,
5147        SymbolKind::FileSummary => 9,
5148    }
5149}
5150
5151fn u8_to_symbol_kind(v: u8) -> SymbolKind {
5152    match v {
5153        0 => SymbolKind::Function,
5154        1 => SymbolKind::Class,
5155        2 => SymbolKind::Method,
5156        3 => SymbolKind::Struct,
5157        4 => SymbolKind::Interface,
5158        5 => SymbolKind::Enum,
5159        6 => SymbolKind::TypeAlias,
5160        7 => SymbolKind::Variable,
5161        8 => SymbolKind::Heading,
5162        9 => SymbolKind::FileSummary,
5163        _ => SymbolKind::Heading,
5164    }
5165}
5166
5167#[cfg(test)]
5168mod tests {
5169    use super::*;
5170    use crate::config::{SemanticBackend, SemanticBackendConfig};
5171    use crate::parser::FileParser;
5172    use std::io::{Read, Write};
5173    use std::net::TcpListener;
5174    use std::process::Command;
5175    use std::thread;
5176    use tempfile::NamedTempFile;
5177
5178    // Only the unix-gated baseline test consumes these (see its comment for
5179    // why Windows cannot reproduce the hash); keep Windows -D warnings clean.
5180    #[cfg(unix)]
5181    const RUST_QUERY_BASELINE_OUTPUT_HASH: &str =
5182        "36315439db74ed8e186076f79ed261079b2b13a4443ed4272861a2518c78d98b";
5183
5184    #[cfg(unix)]
5185    fn rust_fixture_semantic_output_fingerprint(project_root: &Path) -> (usize, usize, String) {
5186        let fixture_root = project_root.join("tests/fixtures");
5187        // Re-materialize the fixtures with LF bytes before collecting: Windows
5188        // checkouts (core.autocrlf) hand collect_chunks CRLF sources, and the
5189        // extra byte per line shifts snippet/embed-text cap boundaries — so
5190        // post-hoc \r stripping cannot reproduce the LF-computed baseline.
5191        let lf_root = tempfile::tempdir().expect("lf fixture root");
5192        let fixture_files = [
5193            "imports_rs.rs",
5194            "member_rs.rs",
5195            "sample.rs",
5196            "structure_rs.rs",
5197        ]
5198        .map(|name| {
5199            let source = std::fs::read_to_string(fixture_root.join(name))
5200                .expect("read fixture")
5201                .replace("\r\n", "\n");
5202            // Preserve the tests/fixtures/<name> layout: chunk identity fields
5203            // (relative path, qualified name, embed-text header) derive from the
5204            // path relative to the project root, so a flat layout re-keys them.
5205            let path = lf_root.path().join("tests/fixtures").join(name);
5206            std::fs::create_dir_all(path.parent().unwrap()).expect("fixture dirs");
5207            std::fs::write(&path, source).expect("write LF fixture");
5208            path
5209        });
5210        let project_root = lf_root.path();
5211        let (chunks, _) = SemanticIndex::collect_chunks(project_root, &fixture_files);
5212        let normalized = chunks
5213            .iter()
5214            .map(|chunk| {
5215                (
5216                    chunk
5217                        .file
5218                        .strip_prefix(project_root)
5219                        .unwrap()
5220                        .to_string_lossy()
5221                        .replace('\\', "/"),
5222                    &chunk.name,
5223                    &chunk.qualified_name,
5224                    &chunk.kind,
5225                    chunk.start_line,
5226                    chunk.end_line,
5227                    chunk.exported,
5228                    &chunk.embed_text,
5229                    &chunk.snippet,
5230                )
5231            })
5232            .collect::<Vec<_>>();
5233        let output = format!("{normalized:#?}");
5234        (
5235            chunks.len(),
5236            output.len(),
5237            blake3::hash(output.as_bytes()).to_hex().to_string(),
5238        )
5239    }
5240
5241    // Unix-only: chunk embed text bakes the OS-native relative path into its
5242    // header (file-summary chunks), so a Windows run hashes "tests\fixtures\…"
5243    // and can never reproduce the unix-captured baseline even with LF-forced
5244    // sources. The property under test — the query-free Rust walk reproduces
5245    // the old RS_QUERY output byte-for-byte — is platform-independent and is
5246    // pinned where the baseline was captured.
5247    #[cfg(unix)]
5248    #[test]
5249    fn rust_semantic_fixture_output_matches_query_baseline() {
5250        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
5251        let (_, _, output_hash) = rust_fixture_semantic_output_fingerprint(&project_root);
5252        assert_eq!(output_hash, RUST_QUERY_BASELINE_OUTPUT_HASH);
5253    }
5254
5255    #[test]
5256    #[ignore = "manual single-file semantic collect phase benchmark"]
5257    fn profile_rust_single_file_semantic_collect() {
5258        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
5259        let workspace_root = crate_root
5260            .parent()
5261            .and_then(Path::parent)
5262            .expect("workspace root");
5263        let files = [
5264            workspace_root.join("crates/aft/src/bash_background/registry.rs"),
5265            workspace_root.join("crates/aft-tokenizer/src/claude_data.rs"),
5266        ];
5267
5268        for file in files {
5269            let source = fs::read_to_string(&file).expect("read benchmark source");
5270            for run in 1..=5 {
5271                let mut phases = SemanticCollectPhaseTimings::default();
5272                let started = Instant::now();
5273                let chunks = collect_file_chunks_from_source_timed(
5274                    workspace_root,
5275                    &file,
5276                    crate::parser::LangId::Rust,
5277                    &source,
5278                    &mut phases,
5279                )
5280                .unwrap();
5281                eprintln!(
5282                    "semantic single-file file={} bytes={} run={run}: total={:?} parse={:?} extract={:?} build={:?} chunks={}",
5283                    file.strip_prefix(workspace_root).unwrap().display(),
5284                    source.len(),
5285                    started.elapsed(),
5286                    phases.parse,
5287                    phases.extract,
5288                    phases.build,
5289                    chunks.len()
5290                );
5291            }
5292        }
5293    }
5294
5295    #[test]
5296    fn semantic_index_includes_php_inc_and_scss_extensions() {
5297        for file in ["partial.inc", "index.php", "styles.scss"] {
5298            assert!(
5299                is_semantic_indexed_extension(Path::new(file)),
5300                "{file} should be semantic-index eligible"
5301            );
5302        }
5303    }
5304
5305    #[test]
5306    fn semantic_index_includes_groovy_extensions_and_jenkinsfile() {
5307        for file in [
5308            "script.groovy",
5309            "script.gvy",
5310            "script.gy",
5311            "shell.gsh",
5312            "build.gradle",
5313            "Jenkinsfile",
5314        ] {
5315            assert!(
5316                is_semantic_indexed_extension(Path::new(file)),
5317                "{file} should be semantic-index eligible"
5318            );
5319        }
5320        assert!(is_semantic_indexed_extension(Path::new("build.gradle.kts")));
5321    }
5322
5323    #[test]
5324    fn transient_marker_round_trips_and_classifies() {
5325        // A marked transient error is recognized and the marker is stripped for
5326        // display, leaving a clean message.
5327        let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
5328        assert!(embedding_failure_is_transient(&marked));
5329        let clean = strip_transient_embedding_marker(&marked);
5330        assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
5331        assert!(clean.starts_with("openai compatible request failed:"));
5332
5333        // Permanent errors (HTTP 4xx, dimension mismatch) carry no marker and
5334        // are not classified transient — they must fail fast.
5335        for permanent in [
5336            "openai compatible request failed (HTTP 401): Unauthorized",
5337            "embedding dimension mismatch: index has 384, model returned 768",
5338            "too many files (>20000) for semantic indexing (max 20000)",
5339        ] {
5340            assert!(
5341                !embedding_failure_is_transient(permanent),
5342                "{permanent:?} must not be transient"
5343            );
5344            // Stripping a marker-free string is a no-op.
5345            assert_eq!(strip_transient_embedding_marker(permanent), permanent);
5346        }
5347    }
5348
5349    #[test]
5350    fn send_error_transience_separates_connect_timeout_from_4xx() {
5351        // 5xx / 429 are transient; other client errors are not.
5352        assert!(is_retryable_embedding_status(
5353            reqwest::StatusCode::INTERNAL_SERVER_ERROR
5354        ));
5355        assert!(is_retryable_embedding_status(
5356            reqwest::StatusCode::TOO_MANY_REQUESTS
5357        ));
5358        assert!(!is_retryable_embedding_status(
5359            reqwest::StatusCode::UNAUTHORIZED
5360        ));
5361        assert!(!is_retryable_embedding_status(
5362            reqwest::StatusCode::BAD_REQUEST
5363        ));
5364    }
5365
5366    #[test]
5367    fn query_timeout_marker_round_trips_and_classifies() {
5368        // A query-timeout error carries the budget that fired; the budget is
5369        // recoverable and the marker strips cleanly for display.
5370        let marked = format!(
5371            "{}openai compatible request failed: operation timed out",
5372            query_embedding_timeout_marker(3_000)
5373        );
5374        assert_eq!(query_embedding_timeout_budget(&marked), Some(3_000));
5375        let clean = strip_query_embedding_timeout_marker(&marked);
5376        assert!(!clean.contains(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX));
5377        assert!(clean.starts_with("openai compatible request failed:"));
5378
5379        // Non-timeout errors carry no marker and no budget — they must not be
5380        // misclassified as timeouts.
5381        for permanent in [
5382            "openai compatible request failed (HTTP 401): Unauthorized",
5383            "failed to embed query: embedding model was not initialized",
5384            "openai compatible request failed: connection refused",
5385        ] {
5386            assert_eq!(
5387                query_embedding_timeout_budget(permanent),
5388                None,
5389                "{permanent:?} must not classify as a query timeout"
5390            );
5391            assert_eq!(
5392                strip_query_embedding_timeout_marker(permanent),
5393                permanent,
5394                "stripping a marker-free string is a no-op"
5395            );
5396        }
5397    }
5398
5399    fn install_test_crypto_provider() {
5400        // Reqwest and the direct test-server dependency enable different rustls
5401        // providers, so select one explicitly before either side builds TLS.
5402        let _ = rustls::crypto::ring::default_provider().install_default();
5403    }
5404
5405    fn start_platform_verifier_tls_server() -> (String, NamedTempFile, thread::JoinHandle<()>) {
5406        install_test_crypto_provider();
5407        let ca_key = rcgen::KeyPair::generate().expect("generate test CA key");
5408        let mut ca_params = rcgen::CertificateParams::default();
5409        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
5410        ca_params.key_usages = vec![
5411            rcgen::KeyUsagePurpose::KeyCertSign,
5412            rcgen::KeyUsagePurpose::DigitalSignature,
5413        ];
5414        let ca_cert = ca_params
5415            .self_signed(&ca_key)
5416            .expect("generate test CA certificate");
5417
5418        let leaf_key = rcgen::KeyPair::generate().expect("generate test leaf key");
5419        let mut leaf_params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
5420            .expect("generate leaf parameters");
5421        leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
5422        leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
5423        let leaf_cert = leaf_params
5424            .signed_by(&leaf_key, &ca_cert, &ca_key)
5425            .expect("sign test leaf certificate");
5426
5427        let mut ca_file = NamedTempFile::new().expect("create test CA file");
5428        ca_file
5429            .write_all(ca_cert.pem().as_bytes())
5430            .expect("write test CA certificate");
5431
5432        let server_config = Arc::new(
5433            rustls::ServerConfig::builder()
5434                .with_no_client_auth()
5435                .with_single_cert(
5436                    vec![rustls::pki_types::CertificateDer::from(
5437                        leaf_cert.der().to_vec(),
5438                    )],
5439                    rustls::pki_types::PrivateKeyDer::Pkcs8(
5440                        rustls::pki_types::PrivatePkcs8KeyDer::from(leaf_key.serialize_der()),
5441                    ),
5442                )
5443                .expect("build test TLS server configuration"),
5444        );
5445        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test TLS server");
5446        let address = listener.local_addr().expect("read test TLS server address");
5447        let url = format!("https://localhost:{}/v1/embeddings", address.port());
5448        let handle = thread::spawn(move || {
5449            // Linux exercises both trust paths: the first handshake fails with
5450            // UnknownIssuer, and the second succeeds after SSL_CERT_FILE supplies
5451            // the throwaway CA. Other platforms only exercise the failure path;
5452            // their platform verifiers do not consult SSL_CERT_FILE.
5453            let expected_connections = if cfg!(target_os = "linux") { 2 } else { 1 };
5454            for _ in 0..expected_connections {
5455                let (stream, _) = listener.accept().expect("accept test TLS connection");
5456                stream
5457                    .set_read_timeout(Some(Duration::from_secs(10)))
5458                    .expect("set test TLS read timeout");
5459                let connection = rustls::ServerConnection::new(server_config.clone())
5460                    .expect("create test TLS server connection");
5461                let mut tls_stream = rustls::StreamOwned::new(connection, stream);
5462                let mut request = [0_u8; 4096];
5463                if tls_stream.read(&mut request).is_ok() {
5464                    let body = r#"{"data":[],"model":"test","object":"list"}"#;
5465                    let response = format!(
5466                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5467                        body.len(), body
5468                    );
5469                    let _ = tls_stream.write_all(response.as_bytes());
5470                    tls_stream.conn.send_close_notify();
5471                    let _ = tls_stream.flush();
5472                }
5473            }
5474        });
5475
5476        (url, ca_file, handle)
5477    }
5478
5479    fn run_platform_verifier_tls_child() {
5480        install_test_crypto_provider();
5481        let url = env::var("AFT_PLATFORM_VERIFIER_TLS_URL").expect("test TLS URL");
5482        let tls_config = crate::platform_tls::client_config().expect("build platform TLS config");
5483        // This test asserts the ERROR CLASS (certificate trust failure), not
5484        // latency, so the budget must be unreachable by keychain slowness: on
5485        // macOS the first evaluation of an untrusted chain walks user trust
5486        // settings (trustd), which a pathological keychain entry plus machine
5487        // load has stretched past 120s — at which point the request surfaces a
5488        // transient "operation timed out" BEFORE the certificate verdict
5489        // exists and the assertion fails on the wrong error class. 120s was
5490        // tried twice and breached twice (485s observed once under load ~100).
5491        // Clean keychains answer in milliseconds; this budget only ever costs
5492        // time on machines with hostile trust settings, where a slow correct
5493        // verdict beats a fast wrong one.
5494        let client = Client::builder()
5495            .timeout(Duration::from_secs(600))
5496            .use_preconfigured_tls(tls_config)
5497            .build()
5498            .expect("build test embedding client");
5499        let result = send_embedding_request(
5500            || client.post(&url).body("{}"),
5501            "openai compatible",
5502            EmbeddingRequestPolicy::Query(QueryBudget {
5503                timeout_ms: 600_000,
5504            }),
5505        );
5506
5507        #[cfg(target_os = "linux")]
5508        if env::var_os("SSL_CERT_FILE").is_some() {
5509            let body = result.expect("SSL_CERT_FILE should make the private CA trusted");
5510            assert!(
5511                body.contains("\"data\""),
5512                "unexpected embedding response: {body}"
5513            );
5514            return;
5515        }
5516
5517        let error = result.expect_err("the private CA must not be trusted on this path");
5518        let lower = error.to_ascii_lowercase();
5519        assert!(
5520            ["certificate", "unknownissuer", "unknown issuer", "trust"]
5521                .iter()
5522                .any(|marker| lower.contains(marker)),
5523            "the rendered source chain must include a certificate trust failure: {error}"
5524        );
5525        assert!(
5526            !embedding_failure_is_transient(&error),
5527            "certificate trust failures must not be retried: {error}"
5528        );
5529    }
5530
5531    #[test]
5532    fn platform_verifier_tls_client_subprocess() {
5533        if env::var_os("AFT_PLATFORM_VERIFIER_TLS_CHILD").is_some() {
5534            run_platform_verifier_tls_child();
5535            return;
5536        }
5537
5538        // Run each trust configuration in a fresh process because the
5539        // TLS/platform-verifier configuration caches CA settings; SSL_CERT_FILE
5540        // must be set before that configuration is initialized for Linux CA
5541        // discovery to use it. The process-env lock prevents this test from
5542        // racing other tests that modify environment variables. macOS and Windows
5543        // exercise only the untrusted path because their platform verifiers do
5544        // not consult SSL_CERT_FILE.
5545        let _env_lock = crate::test_env::process_env_lock();
5546        let (url, _ca_file, server_handle) = start_platform_verifier_tls_server();
5547        let test_name = "semantic_index::tests::platform_verifier_tls_client_subprocess";
5548        #[cfg(target_os = "linux")]
5549        let ca_paths: &[Option<&Path>] = &[None, Some(_ca_file.path())];
5550        #[cfg(not(target_os = "linux"))]
5551        let ca_paths: &[Option<&Path>] = &[None];
5552
5553        for ca_path in ca_paths {
5554            let mut command = Command::new(env::current_exe().expect("test executable"));
5555            command
5556                .args(["--exact", test_name, "--nocapture"])
5557                .env("AFT_PLATFORM_VERIFIER_TLS_CHILD", "1")
5558                .env("AFT_PLATFORM_VERIFIER_TLS_URL", &url)
5559                .env_remove("SSL_CERT_FILE")
5560                .env_remove("SSL_CERT_DIR");
5561            if let Some(ca_path) = ca_path {
5562                command.env("SSL_CERT_FILE", ca_path);
5563            }
5564            let output = command.output().expect("run TLS child test");
5565            // Name the exit status and any terminating signal in the failure:
5566            // under heavy machine load this child has died with EMPTY output,
5567            // and a blind "child failed" leaves nothing to diagnose with.
5568            #[cfg(unix)]
5569            let signal = std::os::unix::process::ExitStatusExt::signal(&output.status);
5570            #[cfg(not(unix))]
5571            let signal: Option<i32> = None;
5572            assert!(
5573                output.status.success(),
5574                "TLS child failed: status={:?} code={:?} signal={:?}\nstdout:\n{}\nstderr:\n{}",
5575                output.status,
5576                output.status.code(),
5577                signal,
5578                String::from_utf8_lossy(&output.stdout),
5579                String::from_utf8_lossy(&output.stderr)
5580            );
5581        }
5582
5583        server_handle.join().expect("join test TLS server");
5584    }
5585
5586    #[test]
5587    fn local_backend_model_loading_body_is_transient() {
5588        // LM Studio / Ollama return a 4xx with a loading/unloaded message while
5589        // the model swaps; these must classify transient so the build self-heals.
5590        for body in [
5591            r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
5592            r#"{"error":"model is loading, please wait"}"#,
5593            r#"{"error":"Model not loaded"}"#,
5594            "Loading model into memory",
5595        ] {
5596            assert!(
5597                embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
5598                "{body:?} should be body-transient"
5599            );
5600        }
5601
5602        // A genuine 4xx misconfiguration body must NOT be treated as transient,
5603        // even when it happens to contain generic words from the old broad
5604        // substring matcher.
5605        for body in [
5606            r#"{"error":"invalid api key"}"#,
5607            r#"{"error":"model 'foo' not found"}"#,
5608            "Bad Request: unknown field",
5609            "Bad Request: invalid loading model option",
5610            r#"{"error":"unauthorized while model is being loaded by another account"}"#,
5611        ] {
5612            assert!(
5613                !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
5614                "{body:?} must not be body-transient"
5615            );
5616        }
5617
5618        assert!(
5619            !embedding_response_body_is_transient(
5620                reqwest::StatusCode::UNAUTHORIZED,
5621                r#"{"error":"model is loading, please wait"}"#
5622            ),
5623            "permanent auth failures must not become transient because of body text"
5624        );
5625    }
5626
5627    fn start_slow_embedding_server(
5628        expected_requests: usize,
5629        response_delay: Duration,
5630    ) -> (String, Arc<AtomicUsize>, thread::JoinHandle<()>) {
5631        let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow embedding server");
5632        listener
5633            .set_nonblocking(true)
5634            .expect("set slow server nonblocking");
5635        let addr = listener.local_addr().expect("slow embedding server addr");
5636        let requests = Arc::new(AtomicUsize::new(0));
5637        let requests_for_thread = Arc::clone(&requests);
5638        let handle = thread::spawn(move || {
5639            let deadline = Instant::now() + Duration::from_secs(10);
5640            let mut handlers = Vec::new();
5641            while requests_for_thread.load(Ordering::SeqCst) < expected_requests
5642                && Instant::now() < deadline
5643            {
5644                match listener.accept() {
5645                    Ok((mut stream, _)) => {
5646                        requests_for_thread.fetch_add(1, Ordering::SeqCst);
5647                        handlers.push(thread::spawn(move || {
5648                            let mut request = [0u8; 4096];
5649                            let _ = stream.read(&mut request);
5650                            thread::sleep(response_delay);
5651                            let body =
5652                                r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
5653                            let response = format!(
5654                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5655                                body.len(),
5656                                body
5657                            );
5658                            let _ = stream.write_all(response.as_bytes());
5659                        }));
5660                    }
5661                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
5662                        thread::sleep(Duration::from_millis(5));
5663                    }
5664                    Err(error) => panic!("accept slow embedding request: {error}"),
5665                }
5666            }
5667            for handler in handlers {
5668                handler.join().expect("slow embedding handler");
5669            }
5670        });
5671
5672        (format!("http://{addr}"), requests, handle)
5673    }
5674
5675    fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
5676    where
5677        F: Fn(String, String, String) -> String + Send + 'static,
5678    {
5679        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
5680        let addr = listener.local_addr().expect("local addr");
5681        let handle = thread::spawn(move || {
5682            let (mut stream, _) = listener.accept().expect("accept request");
5683            let mut buf = Vec::new();
5684            let mut chunk = [0u8; 4096];
5685            let mut header_end = None;
5686            let mut content_length = 0usize;
5687            loop {
5688                let n = stream.read(&mut chunk).expect("read request");
5689                if n == 0 {
5690                    break;
5691                }
5692                buf.extend_from_slice(&chunk[..n]);
5693                if header_end.is_none() {
5694                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
5695                        header_end = Some(pos + 4);
5696                        let headers = String::from_utf8_lossy(&buf[..pos + 4]);
5697                        for line in headers.lines() {
5698                            if let Some(value) = line.strip_prefix("Content-Length:") {
5699                                content_length = value.trim().parse::<usize>().unwrap_or(0);
5700                            }
5701                        }
5702                    }
5703                }
5704                if let Some(end) = header_end {
5705                    if buf.len() >= end + content_length {
5706                        break;
5707                    }
5708                }
5709            }
5710
5711            let end = header_end.expect("header terminator");
5712            let request = String::from_utf8_lossy(&buf[..end]).to_string();
5713            let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
5714            let mut lines = request.lines();
5715            let request_line = lines.next().expect("request line").to_string();
5716            let path = request_line
5717                .split_whitespace()
5718                .nth(1)
5719                .expect("request path")
5720                .to_string();
5721            let response_body = handler(request_line, path, body);
5722            let response = format!(
5723                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5724                response_body.len(),
5725                response_body
5726            );
5727            stream
5728                .write_all(response.as_bytes())
5729                .expect("write response");
5730        });
5731
5732        (format!("http://{}", addr), handle)
5733    }
5734
5735    fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
5736        let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
5737        listener
5738            .set_nonblocking(true)
5739            .expect("nonblocking listener");
5740        let addr = listener.local_addr().expect("local addr");
5741        let handle = thread::spawn(move || {
5742            // The deadline is only a hang-backstop for the case where the client
5743            // makes FEWER than `attempts` connections. It MUST comfortably exceed
5744            // the client's full retry budget (3 attempts: 3x250ms read-timeouts +
5745            // 500ms + 1000ms backoffs ~= 2.25s) so the last connect is always
5746            // accepted — otherwise the 3rd connect lands after a too-short
5747            // deadline, the server thread is already gone, and the client gets a
5748            // connect error ("request failed") instead of the body-read error the
5749            // test asserts. Under loaded CI (esp. Windows) thread scheduling
5750            // drifts the connects later, so this needs generous headroom.
5751            let deadline = std::time::Instant::now() + Duration::from_secs(30);
5752            let mut accepted = 0usize;
5753            while accepted < attempts && std::time::Instant::now() < deadline {
5754                match listener.accept() {
5755                    Ok((mut stream, _)) => {
5756                        accepted += 1;
5757                        let mut buf = [0u8; 4096];
5758                        // The client (under test) uses a 250ms timeout and drops
5759                        // the connection when the truncated body never completes.
5760                        // On Windows that disconnect surfaces as a hard socket
5761                        // error (WSAECONNRESET) on these read/write calls, where
5762                        // Unix returns a clean EOF. Tolerate both: the mock does
5763                        // not need the request bytes, and a write to an
5764                        // already-hung-up client is expected.
5765                        let _ = stream.read(&mut buf);
5766                        let response = "HTTP/1.1 200 OK
5767Content-Type: application/json
5768Content-Length: 128
5769Connection: close
5770
5771{";
5772                        let _ = stream.write_all(response.as_bytes());
5773                    }
5774                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
5775                        thread::sleep(Duration::from_millis(10));
5776                    }
5777                    Err(error) => panic!("accept request: {error}"),
5778                }
5779            }
5780        });
5781
5782        (format!("http://{}", addr), handle)
5783    }
5784
5785    #[test]
5786    fn response_body_read_failures_are_marked_transient() {
5787        let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
5788        // Generous client timeout: this test classifies BODY-TRUNCATION errors,
5789        // and a tight budget flips the failure into a connect/send timeout on a
5790        // loaded machine, changing which error string the assertions see.
5791        let client = Client::builder()
5792            .timeout(Duration::from_secs(5))
5793            .build()
5794            .expect("client");
5795
5796        let error = send_embedding_request(
5797            || client.post(&url).body("{}"),
5798            "test backend",
5799            EmbeddingRequestPolicy::Build,
5800        )
5801        .expect_err("truncated body should fail");
5802
5803        handle.join().unwrap();
5804        assert!(
5805            embedding_failure_is_transient(&error),
5806            "body read failures should be transient-marked: {error}"
5807        );
5808        // The mock closes the socket after writing a truncated body. Whether
5809        // the client observes that as a body-read EOF or as a send-stage
5810        // connection reset is an OS-level race (Windows sends RST when the
5811        // socket closes with unread request bytes, and under load the mock's
5812        // single read can return early). Both shapes are the backend dying
5813        // mid-exchange and both must carry the transient marker; the message
5814        // prefix differs by stage.
5815        assert!(
5816            error.contains("response read failed") || error.contains("request failed"),
5817            "unexpected error shape: {error}"
5818        );
5819    }
5820
5821    fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
5822        Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
5823    }
5824
5825    fn write_rust_file(path: &Path, function_name: &str) {
5826        fs::write(
5827            path,
5828            format!("pub fn {function_name}() -> bool {{\n    true\n}}\n"),
5829        )
5830        .unwrap();
5831    }
5832
5833    fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
5834        let mut embed = test_vector_for_texts;
5835        SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
5836    }
5837
5838    fn test_project_root() -> PathBuf {
5839        std::env::current_dir().unwrap()
5840    }
5841
5842    #[test]
5843    fn empty_snapshot_replaces_nonempty_and_loads_as_valid_tombstone() {
5844        let project = tempfile::tempdir().expect("create project");
5845        let storage = tempfile::tempdir().expect("create storage");
5846        let source = project.path().join("lib.rs");
5847        write_rust_file(&source, "persisted_symbol");
5848        let populated = build_test_index(project.path(), std::slice::from_ref(&source));
5849        assert!(populated.write_to_disk(storage.path(), "project"));
5850
5851        let data_path = storage.path().join("semantic/project/semantic.bin");
5852        let populated_bytes = fs::read(&data_path).expect("read populated snapshot");
5853        let empty = SemanticIndex::new(project.path().to_path_buf(), populated.dimension());
5854        assert!(empty.write_to_disk(storage.path(), "project"));
5855        let empty_bytes = fs::read(&data_path).expect("read explicit empty snapshot");
5856        assert_ne!(empty_bytes, populated_bytes);
5857        let decoded = SemanticIndex::from_bytes(&empty_bytes, project.path())
5858            .expect("decode explicit empty snapshot");
5859        assert_eq!(decoded.entry_count(), 0);
5860        for _ in 0..2 {
5861            let loaded = SemanticIndex::read_from_disk(
5862                storage.path(),
5863                "project",
5864                project.path(),
5865                false,
5866                None,
5867            )
5868            .expect("explicit empty snapshot remains loadable");
5869            assert_eq!(loaded.entry_count(), 0);
5870        }
5871    }
5872
5873    #[test]
5874    fn persistence_failure_is_reported_to_caller() {
5875        let project = tempfile::tempdir().expect("create project");
5876        let storage_parent = tempfile::tempdir().expect("create storage parent");
5877        let storage_file = storage_parent.path().join("not-a-directory");
5878        fs::write(&storage_file, b"occupied").expect("create blocking file");
5879        let empty = SemanticIndex::new(project.path().to_path_buf(), 3);
5880
5881        assert!(!empty.write_to_disk(&storage_file, "project"));
5882    }
5883
5884    #[test]
5885    fn semantic_memory_estimate_is_zero_when_empty_and_scales_with_entries() {
5886        let root = test_project_root();
5887        let mut index = SemanticIndex::new(root.clone(), 3);
5888        assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
5889
5890        let entry = |name: &str| EmbeddingEntry {
5891            chunk: SemanticChunk {
5892                file: root.join(format!("{name}.rs")),
5893                name: name.to_string(),
5894                qualified_name: Some(format!("module::{name}")),
5895                kind: SymbolKind::Function,
5896                start_line: 0,
5897                end_line: 1,
5898                exported: true,
5899                embed_text: format!("function {name} body"),
5900                snippet: format!("fn {name}() {{}}"),
5901            },
5902            norm: vector_norm(&[1.0, 2.0, 3.0]),
5903            vector: vec![1.0, 2.0, 3.0],
5904        };
5905        index.entries.push(entry("one"));
5906        let one_entry = index.estimated_memory().estimated_bytes.unwrap();
5907        assert!(one_entry > 0);
5908        index.entries.push(entry("two"));
5909        let two_entries = index.estimated_memory().estimated_bytes.unwrap();
5910        assert!(two_entries > one_entry);
5911    }
5912
5913    fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
5914        index.file_mtimes.insert(file.to_path_buf(), mtime);
5915        index.file_sizes.insert(file.to_path_buf(), size);
5916        index
5917            .file_hashes
5918            .insert(file.to_path_buf(), cache_freshness::zero_hash());
5919    }
5920
5921    fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
5922        let mut buf = Vec::new();
5923        let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
5924            let encoded = fingerprint.as_string();
5925            if encoded.is_empty() {
5926                None
5927            } else {
5928                Some(encoded.into_bytes())
5929            }
5930        });
5931        let file_mtimes: Vec<_> = index
5932            .file_mtimes
5933            .iter()
5934            .filter_map(|(path, mtime)| {
5935                cache_relative_path(&index.project_root, path)
5936                    .map(|relative| (relative, path, mtime))
5937            })
5938            .collect();
5939        let entries: Vec<_> = index
5940            .entries
5941            .iter()
5942            .filter_map(|entry| {
5943                cache_relative_path(&index.project_root, &entry.chunk.file)
5944                    .map(|relative| (relative, entry))
5945            })
5946            .collect();
5947
5948        buf.push(SEMANTIC_INDEX_VERSION_V6);
5949        buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
5950        buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
5951        let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
5952        buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
5953        buf.extend_from_slice(fp_bytes_ref);
5954
5955        buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
5956        for (relative, path, mtime) in &file_mtimes {
5957            let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
5958            buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
5959            buf.extend_from_slice(&path_bytes);
5960            let duration = mtime
5961                .duration_since(SystemTime::UNIX_EPOCH)
5962                .unwrap_or_default();
5963            buf.extend_from_slice(&duration.as_secs().to_le_bytes());
5964            buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
5965            let size = index.file_sizes.get(*path).copied().unwrap_or_default();
5966            buf.extend_from_slice(&size.to_le_bytes());
5967            let hash = index
5968                .file_hashes
5969                .get(*path)
5970                .copied()
5971                .unwrap_or_else(cache_freshness::zero_hash);
5972            buf.extend_from_slice(hash.as_bytes());
5973        }
5974
5975        for (relative, entry) in &entries {
5976            let c = &entry.chunk;
5977            let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
5978            buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
5979            buf.extend_from_slice(&file_bytes);
5980
5981            let name_bytes = c.name.as_bytes();
5982            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
5983            buf.extend_from_slice(name_bytes);
5984
5985            buf.push(symbol_kind_to_u8(&c.kind));
5986            buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
5987            buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
5988            buf.push(c.exported as u8);
5989
5990            let snippet_bytes = c.snippet.as_bytes();
5991            buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
5992            buf.extend_from_slice(snippet_bytes);
5993
5994            let embed_bytes = c.embed_text.as_bytes();
5995            buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
5996            buf.extend_from_slice(embed_bytes);
5997
5998            for &val in &entry.vector {
5999                buf.extend_from_slice(&val.to_le_bytes());
6000            }
6001        }
6002
6003        buf
6004    }
6005
6006    #[derive(Default)]
6007    struct RecordingEmbedder {
6008        calls: Vec<Vec<String>>,
6009    }
6010
6011    impl RecordingEmbedder {
6012        fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
6013            let vectors = texts
6014                .iter()
6015                .map(|text| deterministic_test_vector(text))
6016                .collect();
6017            self.calls.push(texts);
6018            Ok(vectors)
6019        }
6020
6021        fn total_embedded_texts(&self) -> usize {
6022            self.calls.iter().map(Vec::len).sum()
6023        }
6024
6025        fn embedded_texts(&self) -> Vec<&str> {
6026            self.calls
6027                .iter()
6028                .flat_map(|batch| batch.iter().map(String::as_str))
6029                .collect()
6030        }
6031    }
6032
6033    fn deterministic_test_vector(text: &str) -> Vec<f32> {
6034        let hash = blake3::hash(text.as_bytes());
6035        let bytes = hash.as_bytes();
6036        vec![
6037            1.0,
6038            bytes[0] as f32 / 255.0,
6039            bytes[1] as f32 / 255.0,
6040            bytes[2] as f32 / 255.0,
6041        ]
6042    }
6043
6044    fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
6045        let mut embedder = RecordingEmbedder::default();
6046        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6047        SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
6048    }
6049
6050    fn force_stale(index: &mut SemanticIndex, file: &Path) {
6051        set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
6052    }
6053
6054    fn write_source(path: &Path, source: &str) {
6055        if let Some(parent) = path.parent() {
6056            fs::create_dir_all(parent).unwrap();
6057        }
6058        fs::write(path, source).unwrap();
6059    }
6060
6061    fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
6062        index
6063            .entries
6064            .iter()
6065            .filter(|entry| entry.chunk.file == file)
6066            .collect()
6067    }
6068
6069    fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
6070        index
6071            .entries
6072            .iter()
6073            .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
6074            .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
6075    }
6076
6077    fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
6078        index
6079            .entries
6080            .iter()
6081            .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
6082            .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
6083    }
6084
6085    #[test]
6086    fn borrowed_snapshots_deserialize_once_share_memory_and_drop_with_last_holder() {
6087        let owner = tempfile::tempdir().unwrap();
6088        let storage = tempfile::tempdir().unwrap();
6089        let borrower_a = tempfile::tempdir().unwrap();
6090        let borrower_b = tempfile::tempdir().unwrap();
6091        let relative = Path::new("src/lib.rs");
6092        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
6093            let file = root.join(relative);
6094            fs::create_dir_all(file.parent().unwrap()).unwrap();
6095            fs::write(&file, "pub fn shared_symbol() -> bool { true }\n").unwrap();
6096        }
6097        let owner_file = owner.path().join(relative);
6098        let metadata = fs::metadata(&owner_file).unwrap();
6099        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 3);
6100        index.entries.push(EmbeddingEntry {
6101            chunk: SemanticChunk {
6102                file: owner_file.clone(),
6103                name: "shared_symbol".to_string(),
6104                qualified_name: None,
6105                kind: SymbolKind::Function,
6106                start_line: 0,
6107                end_line: 0,
6108                exported: true,
6109                embed_text: "shared symbol".to_string(),
6110                snippet: "pub fn shared_symbol() -> bool { true }".to_string(),
6111            },
6112            norm: vector_norm(&[1.0, 0.0, 0.0]),
6113            vector: vec![1.0, 0.0, 0.0],
6114        });
6115        index.file_mtimes.insert(
6116            owner_file.clone(),
6117            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
6118        );
6119        index.file_sizes.insert(owner_file.clone(), metadata.len());
6120        index.file_hashes.insert(
6121            owner_file,
6122            blake3::hash(b"pub fn shared_symbol() -> bool { true }\n"),
6123        );
6124        index.set_fingerprint(SemanticIndexFingerprint {
6125            backend: "test".to_string(),
6126            model: "shared-base".to_string(),
6127            base_url: FALLBACK_BACKEND.to_string(),
6128            dimension: 3,
6129            chunking_version: default_chunking_version(),
6130            ..Default::default()
6131        });
6132        assert!(index.shared_base.is_none(), "owner indexes stay private");
6133
6134        let project_key = format!(
6135            "shared-base-{}",
6136            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
6137        );
6138        let dir = storage.path().join("semantic").join(&project_key);
6139        fs::create_dir_all(&dir).unwrap();
6140        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
6141        let loads_before = SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed);
6142        let hits_before = SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed);
6143        let a = SemanticIndex::read_from_disk_borrow_tolerant(
6144            storage.path(),
6145            &project_key,
6146            borrower_a.path(),
6147        )
6148        .unwrap();
6149        let b = SemanticIndex::read_from_disk_borrow_tolerant(
6150            storage.path(),
6151            &project_key,
6152            borrower_b.path(),
6153        )
6154        .unwrap();
6155        let a_base = a.shared_base.as_ref().unwrap();
6156        let b_base = b.shared_base.as_ref().unwrap();
6157        assert!(Arc::ptr_eq(a_base, b_base));
6158        assert!(SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) > loads_before);
6159        assert!(SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) > hits_before);
6160        assert_eq!(
6161            a.search(&[1.0, 0.0, 0.0], 1)[0].file,
6162            borrower_a.path().join(relative)
6163        );
6164        assert_eq!(
6165            b.search(&[1.0, 0.0, 0.0], 1)[0].file,
6166            borrower_b.path().join(relative)
6167        );
6168        assert_eq!(a.estimated_memory().estimated_bytes, Some(0));
6169        assert!(shared_semantic_bases_memory().estimated_bytes.unwrap_or(0) > 0);
6170
6171        let weak = Arc::downgrade(a_base);
6172        let ctx = crate::context::AppContext::new(
6173            Box::new(crate::parser::TreeSitterProvider::new()),
6174            crate::config::Config {
6175                project_root: Some(borrower_a.path().to_path_buf()),
6176                ..crate::config::Config::default()
6177            },
6178        );
6179        *ctx.semantic_index()
6180            .write()
6181            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(a);
6182        assert!(ctx.evict_idle_artifacts());
6183        assert!(
6184            weak.upgrade().is_some(),
6185            "the second borrower keeps the base live"
6186        );
6187        drop(b);
6188        assert!(
6189            weak.upgrade().is_none(),
6190            "the last borrower releases the base"
6191        );
6192    }
6193
6194    #[test]
6195    fn borrowed_snapshot_hash_change_falls_back_to_private_copy() {
6196        let owner = tempfile::tempdir().unwrap();
6197        let storage = tempfile::tempdir().unwrap();
6198        let borrower_a = tempfile::tempdir().unwrap();
6199        let borrower_b = tempfile::tempdir().unwrap();
6200        let relative = Path::new("src/lib.rs");
6201        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
6202            let file = root.join(relative);
6203            fs::create_dir_all(file.parent().unwrap()).unwrap();
6204            fs::write(&file, "pub fn hash_guard() {}\n").unwrap();
6205        }
6206        let owner_file = owner.path().join(relative);
6207        let metadata = fs::metadata(&owner_file).unwrap();
6208        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 2);
6209        index.entries.push(EmbeddingEntry {
6210            chunk: SemanticChunk {
6211                file: owner_file.clone(),
6212                name: "hash_guard".to_string(),
6213                qualified_name: None,
6214                kind: SymbolKind::Function,
6215                start_line: 0,
6216                end_line: 0,
6217                exported: true,
6218                embed_text: "hash guard".to_string(),
6219                snippet: "pub fn hash_guard() {}".to_string(),
6220            },
6221            norm: vector_norm(&[1.0, 0.0]),
6222            vector: vec![1.0, 0.0],
6223        });
6224        index.file_mtimes.insert(
6225            owner_file.clone(),
6226            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
6227        );
6228        index.file_sizes.insert(owner_file.clone(), metadata.len());
6229        index
6230            .file_hashes
6231            .insert(owner_file, blake3::hash(b"pub fn hash_guard() {}\n"));
6232        index.set_fingerprint(SemanticIndexFingerprint {
6233            backend: "test".to_string(),
6234            model: "hash-guard".to_string(),
6235            base_url: FALLBACK_BACKEND.to_string(),
6236            dimension: 2,
6237            chunking_version: default_chunking_version(),
6238            ..Default::default()
6239        });
6240        let project_key = format!(
6241            "hash-fallback-{}",
6242            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
6243        );
6244        let dir = storage.path().join("semantic").join(&project_key);
6245        fs::create_dir_all(&dir).unwrap();
6246        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
6247        let shared = SemanticIndex::read_from_disk_borrow_tolerant(
6248            storage.path(),
6249            &project_key,
6250            borrower_a.path(),
6251        )
6252        .unwrap();
6253        assert!(shared.shared_base.is_some());
6254
6255        let changed_vector = vec![0.0, 1.0];
6256        index.entries[0].norm = vector_norm(&changed_vector);
6257        index.entries[0].vector = changed_vector;
6258        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
6259        let fallback = SemanticIndex::read_from_disk_borrow_tolerant(
6260            storage.path(),
6261            &project_key,
6262            borrower_b.path(),
6263        )
6264        .unwrap();
6265        assert!(
6266            fallback.shared_base.is_none(),
6267            "a different byte identity must not join the live shared generation"
6268        );
6269        drop(shared);
6270    }
6271
6272    #[test]
6273    fn borrow_only_root_skips_semantic_lock_and_persist() {
6274        let project = tempfile::tempdir().expect("project");
6275        let source = project.path().join("lib.rs");
6276        write_rust_file(&source, "borrow_only_symbol");
6277        let project_key = "shared-artifact-key".to_string();
6278        let storage = tempfile::tempdir().expect("storage");
6279        crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
6280
6281        let _lock = SemanticIndexLock::acquire(storage.path(), &project_key, project.path())
6282            .expect("borrow-only lock downgrade");
6283        let cache_dir = storage.path().join("semantic").join(&project_key);
6284        assert!(!cache_dir.join("cache.lock").exists());
6285
6286        let index = build_test_index(project.path(), &[source]);
6287        index.write_to_disk(storage.path(), &project_key);
6288
6289        assert!(!cache_dir.join("semantic.bin").exists());
6290        assert!(!cache_dir.exists());
6291    }
6292
6293    #[test]
6294    fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
6295        let temp = tempfile::tempdir().unwrap();
6296        let project_root = temp.path();
6297        let file = project_root.join("src/lib.rs");
6298        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
6299        write_source(&file, original);
6300
6301        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6302        let original_entry_count = index.entries.len();
6303        let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
6304
6305        write_source(&file, &format!("\n{original}"));
6306        force_stale(&mut index, &file);
6307
6308        let mut embedder = RecordingEmbedder::default();
6309        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6310        let mut progress = |_done: usize, _total: usize| {};
6311        let summary = index
6312            .refresh_stale_files(
6313                project_root,
6314                std::slice::from_ref(&file),
6315                &mut embed,
6316                16,
6317                &mut progress,
6318            )
6319            .unwrap();
6320
6321        assert_eq!(summary.changed, 1);
6322        assert_eq!(embedder.total_embedded_texts(), 0);
6323        assert_eq!(index.entries.len(), original_entry_count);
6324        let shifted_alpha = entry_by_name(&index, &file, "alpha");
6325        assert_eq!(shifted_alpha.chunk.start_line, 1);
6326        assert_eq!(shifted_alpha.vector, original_alpha_vector);
6327    }
6328
6329    #[test]
6330    fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
6331        let temp = tempfile::tempdir().unwrap();
6332        let project_root = temp.path();
6333        let file = project_root.join("src/lib.rs");
6334        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
6335        write_source(&file, original);
6336
6337        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6338        let mut serving_index = worker_index.clone();
6339        let original_entry_count = worker_index.entries.len();
6340
6341        write_source(&file, &format!("\n{original}"));
6342
6343        let mut embedder = RecordingEmbedder::default();
6344        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6345        let mut progress = |_done: usize, _total: usize| {};
6346        let update = worker_index
6347            .refresh_invalidated_files(
6348                project_root,
6349                std::slice::from_ref(&file),
6350                &mut embed,
6351                16,
6352                100,
6353                &mut progress,
6354            )
6355            .unwrap();
6356
6357        assert_eq!(embedder.total_embedded_texts(), 0);
6358        assert_eq!(update.added_entries.len(), original_entry_count);
6359        assert_eq!(worker_index.entries.len(), original_entry_count);
6360
6361        serving_index.apply_refresh_update(
6362            update.added_entries,
6363            update.updated_metadata,
6364            &update.completed_paths,
6365        );
6366
6367        assert_eq!(serving_index.entries.len(), original_entry_count);
6368        assert_eq!(
6369            entries_for_file(&serving_index, &file).len(),
6370            original_entry_count
6371        );
6372        assert_eq!(
6373            entry_by_name(&serving_index, &file, "alpha")
6374                .chunk
6375                .start_line,
6376            1
6377        );
6378    }
6379
6380    #[test]
6381    fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
6382        let temp = tempfile::tempdir().unwrap();
6383        let project_root = temp.path();
6384        let file = project_root.join("src/lib.rs");
6385        write_source(
6386            &file,
6387            "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
6388        );
6389
6390        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6391        let original_entry_count = index.entries.len();
6392        let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
6393
6394        write_source(
6395            &file,
6396            "pub fn alpha() -> i32 {\n    10\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
6397        );
6398
6399        let mut embedder = RecordingEmbedder::default();
6400        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6401        let mut progress = |_done: usize, _total: usize| {};
6402        let update = index
6403            .refresh_invalidated_files(
6404                project_root,
6405                std::slice::from_ref(&file),
6406                &mut embed,
6407                16,
6408                100,
6409                &mut progress,
6410            )
6411            .unwrap();
6412
6413        assert_eq!(embedder.total_embedded_texts(), 1);
6414        assert!(embedder.embedded_texts()[0].contains("name:alpha"));
6415        assert_eq!(update.added_entries.len(), original_entry_count);
6416        assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
6417    }
6418
6419    #[test]
6420    fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
6421        let temp = tempfile::tempdir().unwrap();
6422        let project_root = temp.path();
6423        let file = project_root.join("src/dupe.js");
6424        let one_duplicate = "function duplicate() {\n  return 1;\n}\n";
6425        write_source(&file, one_duplicate);
6426
6427        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6428        let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
6429
6430        write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
6431
6432        let mut embedder = RecordingEmbedder::default();
6433        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6434        let mut progress = |_done: usize, _total: usize| {};
6435        index
6436            .refresh_invalidated_files(
6437                project_root,
6438                std::slice::from_ref(&file),
6439                &mut embed,
6440                16,
6441                100,
6442                &mut progress,
6443            )
6444            .unwrap();
6445
6446        let duplicate_entries = index
6447            .entries
6448            .iter()
6449            .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
6450            .collect::<Vec<_>>();
6451        assert_eq!(duplicate_entries.len(), 2);
6452        assert_eq!(embedder.total_embedded_texts(), 0);
6453        assert_eq!(duplicate_entries[0].vector, original_vector);
6454        assert_eq!(duplicate_entries[1].vector, original_vector);
6455    }
6456
6457    #[test]
6458    fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
6459        let temp = tempfile::tempdir().unwrap();
6460        let project_root = temp.path();
6461        let file = project_root.join("src/lib.rs");
6462        write_source(
6463            &file,
6464            "//! module docs v1\n\npub fn alpha() -> i32 {\n    1\n}\n",
6465        );
6466
6467        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6468        let summary_before = file_summary_entry(&index, &file).vector.clone();
6469
6470        write_source(
6471            &file,
6472            "//! module docs v1\n\npub fn alpha() -> i32 {\n    2\n}\n",
6473        );
6474        let mut body_embedder = RecordingEmbedder::default();
6475        let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
6476        let mut progress = |_done: usize, _total: usize| {};
6477        index
6478            .refresh_invalidated_files(
6479                project_root,
6480                std::slice::from_ref(&file),
6481                &mut body_embed,
6482                16,
6483                100,
6484                &mut progress,
6485            )
6486            .unwrap();
6487        assert_eq!(body_embedder.total_embedded_texts(), 1);
6488        assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
6489        assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
6490
6491        write_source(
6492            &file,
6493            "//! module docs v2\n\npub fn alpha() -> i32 {\n    2\n}\n",
6494        );
6495        let mut doc_embedder = RecordingEmbedder::default();
6496        let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
6497        index
6498            .refresh_invalidated_files(
6499                project_root,
6500                std::slice::from_ref(&file),
6501                &mut doc_embed,
6502                16,
6503                100,
6504                &mut progress,
6505            )
6506            .unwrap();
6507
6508        assert_eq!(doc_embedder.total_embedded_texts(), 1);
6509        assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
6510        assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
6511    }
6512
6513    #[test]
6514    fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
6515        let temp = tempfile::tempdir().unwrap();
6516        let project_root = temp.path();
6517        let file = project_root.join("src/lib.rs");
6518        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
6519
6520        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6521        let mut serving_index = worker_index.clone();
6522        fs::remove_file(&file).unwrap();
6523
6524        let mut embedder = RecordingEmbedder::default();
6525        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6526        let mut progress = |_done: usize, _total: usize| {};
6527        let update = worker_index
6528            .refresh_invalidated_files(
6529                project_root,
6530                std::slice::from_ref(&file),
6531                &mut embed,
6532                16,
6533                100,
6534                &mut progress,
6535            )
6536            .unwrap();
6537
6538        assert_eq!(update.summary.deleted, 1);
6539        assert_eq!(embedder.total_embedded_texts(), 0);
6540        assert!(worker_index.entries.is_empty());
6541
6542        serving_index.apply_refresh_update(
6543            update.added_entries,
6544            update.updated_metadata,
6545            &update.completed_paths,
6546        );
6547        assert!(serving_index.entries.is_empty());
6548    }
6549
6550    #[test]
6551    fn watcher_collect_failure_does_not_resurrect_stale_entries() {
6552        let temp = tempfile::tempdir().unwrap();
6553        let project_root = temp.path();
6554        let file = project_root.join("src/lib.rs");
6555        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
6556
6557        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
6558        let mut serving_index = worker_index.clone();
6559        fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
6560
6561        let mut embedder = RecordingEmbedder::default();
6562        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6563        let mut progress = |_done: usize, _total: usize| {};
6564        let update = worker_index
6565            .refresh_invalidated_files(
6566                project_root,
6567                std::slice::from_ref(&file),
6568                &mut embed,
6569                16,
6570                100,
6571                &mut progress,
6572            )
6573            .unwrap();
6574
6575        assert_eq!(embedder.total_embedded_texts(), 0);
6576        assert!(update.added_entries.is_empty());
6577        assert!(worker_index.entries.is_empty());
6578        assert!(!worker_index.file_mtimes.contains_key(&file));
6579
6580        serving_index.apply_refresh_update(
6581            update.added_entries,
6582            update.updated_metadata,
6583            &update.completed_paths,
6584        );
6585        assert!(serving_index.entries.is_empty());
6586        assert!(!serving_index.file_mtimes.contains_key(&file));
6587    }
6588
6589    #[test]
6590    fn refresh_invalidated_cap_deferral_remains_file_count_based() {
6591        let temp = tempfile::tempdir().unwrap();
6592        let project_root = temp.path();
6593        let indexed = project_root.join("src/a.rs");
6594        let deferred = project_root.join("src/b.rs");
6595        write_source(&indexed, "pub fn alpha() -> i32 {\n    1\n}\n");
6596        write_source(&deferred, "pub fn beta() -> i32 {\n    2\n}\n");
6597
6598        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
6599        let mut embedder = RecordingEmbedder::default();
6600        let mut embed = |texts: Vec<String>| embedder.embed(texts);
6601        let mut progress = |_done: usize, _total: usize| {};
6602        let update = index
6603            .refresh_invalidated_files(
6604                project_root,
6605                std::slice::from_ref(&deferred),
6606                &mut embed,
6607                16,
6608                1,
6609                &mut progress,
6610            )
6611            .unwrap();
6612
6613        assert_eq!(update.summary.total_processed, 1);
6614        assert_eq!(update.summary.added, 0);
6615        assert_eq!(embedder.total_embedded_texts(), 0);
6616        assert_eq!(index.indexed_file_count(), 1);
6617        assert!(index.deferred_files.contains(&deferred));
6618        assert!(entries_for_file(&index, &deferred).is_empty());
6619    }
6620
6621    #[test]
6622    fn semantic_cache_serialization_skips_paths_outside_project_root() {
6623        let dir = tempfile::tempdir().expect("create temp dir");
6624        let project = fs::canonicalize(dir.path()).expect("canonical project");
6625        let outside = project.join("..").join("outside.rs");
6626        let mut index = SemanticIndex::new(project.clone(), 3);
6627        index
6628            .file_mtimes
6629            .insert(outside.clone(), SystemTime::UNIX_EPOCH);
6630        index.file_sizes.insert(outside.clone(), 1);
6631        index
6632            .file_hashes
6633            .insert(outside.clone(), cache_freshness::zero_hash());
6634        index.entries.push(EmbeddingEntry {
6635            chunk: SemanticChunk {
6636                file: outside,
6637                name: "outside".to_string(),
6638                qualified_name: None,
6639                kind: SymbolKind::Function,
6640                start_line: 0,
6641                end_line: 0,
6642                exported: false,
6643                embed_text: "outside".to_string(),
6644                snippet: "outside".to_string(),
6645            },
6646            norm: vector_norm(&[1.0, 0.0, 0.0]),
6647            vector: vec![1.0, 0.0, 0.0],
6648        });
6649
6650        let bytes = index.to_bytes();
6651        let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
6652        assert_eq!(loaded.entries.len(), 0);
6653        assert!(loaded.file_mtimes.is_empty());
6654    }
6655
6656    #[test]
6657    fn semantic_search_bounded_top_k_matches_reference_full_sort() {
6658        let project_root = test_project_root();
6659        let file = project_root.join("src/lib.rs");
6660        let mut index = SemanticIndex::new(project_root, 2);
6661        let entries = [
6662            ("alpha", vec![2.0, 0.0], false),
6663            ("beta", vec![0.0, 3.0], false),
6664            ("gamma", vec![4.0, 0.0], false),
6665            ("delta", vec![1.0, 1.0], true),
6666            ("epsilon", vec![-5.0, 0.0], false),
6667        ];
6668        for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
6669            index.entries.push(EmbeddingEntry {
6670                chunk: SemanticChunk {
6671                    file: file.clone(),
6672                    name: name.to_string(),
6673                    qualified_name: None,
6674                    kind: SymbolKind::Function,
6675                    start_line: line as u32 + 1,
6676                    end_line: line as u32 + 1,
6677                    exported,
6678                    embed_text: name.to_string(),
6679                    snippet: format!("fn {name}() {{}}"),
6680                },
6681                norm: vector_norm(&vector),
6682                vector,
6683            });
6684        }
6685
6686        let query = vec![2.0, 0.0];
6687        let top_k = 4;
6688        let mut reference: Vec<(f32, usize)> = index
6689            .entries
6690            .iter()
6691            .enumerate()
6692            .map(|(idx, entry)| {
6693                // Recompute both norms for every entry as the reference
6694                // implementation, so cached norms cannot change ranking or scores.
6695                let mut dot = 0.0f32;
6696                let mut query_squared_norm = 0.0f32;
6697                let mut entry_squared_norm = 0.0f32;
6698                for i in 0..query.len() {
6699                    dot += query[i] * entry.vector[i];
6700                    query_squared_norm += query[i] * query[i];
6701                    entry_squared_norm += entry.vector[i] * entry.vector[i];
6702                }
6703                let denom = query_squared_norm.sqrt() * entry_squared_norm.sqrt();
6704                let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
6705                if entry.chunk.exported {
6706                    score *= 1.1;
6707                }
6708                (score, idx)
6709            })
6710            .collect();
6711        reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
6712        let expected: Vec<(String, f32)> = reference
6713            .into_iter()
6714            .take(top_k)
6715            .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
6716            .collect();
6717
6718        let actual: Vec<(String, f32)> = index
6719            .search(&query, top_k)
6720            .into_iter()
6721            .map(|result| (result.name, result.score))
6722            .collect();
6723
6724        assert_eq!(
6725            actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
6726            expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
6727        );
6728        for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
6729            assert!((actual_score - expected_score).abs() < 1e-6);
6730        }
6731        assert_eq!(actual[0].0, "alpha");
6732        assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
6733        assert!(index.search(&query, 0).is_empty());
6734    }
6735
6736    #[test]
6737    fn test_cosine_similarity_identical() {
6738        let a = vec![1.0, 0.0, 0.0];
6739        let b = vec![1.0, 0.0, 0.0];
6740        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
6741    }
6742
6743    #[test]
6744    fn test_cosine_similarity_orthogonal() {
6745        let a = vec![1.0, 0.0, 0.0];
6746        let b = vec![0.0, 1.0, 0.0];
6747        assert!(cosine_similarity(&a, &b).abs() < 0.001);
6748    }
6749
6750    #[test]
6751    fn test_cosine_similarity_opposite() {
6752        let a = vec![1.0, 0.0, 0.0];
6753        let b = vec![-1.0, 0.0, 0.0];
6754        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
6755    }
6756
6757    #[test]
6758    fn test_serialization_roundtrip() {
6759        let project_root = test_project_root();
6760        let file = project_root.join("src/main.rs");
6761        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
6762        index.entries.push(EmbeddingEntry {
6763            chunk: SemanticChunk {
6764                file: file.clone(),
6765                name: "handle_request".to_string(),
6766                qualified_name: None,
6767                kind: SymbolKind::Function,
6768                start_line: 10,
6769                end_line: 25,
6770                exported: true,
6771                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
6772                snippet: "fn handle_request() {\n  // ...\n}".to_string(),
6773            },
6774            norm: vector_norm(&[0.1, 0.2, 0.3, 0.4]),
6775            vector: vec![0.1, 0.2, 0.3, 0.4],
6776        });
6777        index.dimension = 4;
6778        index
6779            .file_mtimes
6780            .insert(file.clone(), SystemTime::UNIX_EPOCH);
6781        index.file_sizes.insert(file, 0);
6782        index.set_fingerprint(SemanticIndexFingerprint {
6783            backend: "fastembed".to_string(),
6784            model: "all-MiniLM-L6-v2".to_string(),
6785            base_url: FALLBACK_BACKEND.to_string(),
6786            dimension: 4,
6787            chunking_version: default_chunking_version(),
6788            ..Default::default()
6789        });
6790
6791        let bytes = index.to_bytes();
6792        let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
6793
6794        assert_eq!(restored.entries.len(), 1);
6795        assert_eq!(restored.entries[0].chunk.name, "handle_request");
6796        assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
6797        assert_eq!(
6798            restored.entries[0].norm,
6799            vector_norm(&restored.entries[0].vector)
6800        );
6801        assert_eq!(restored.dimension, 4);
6802        assert_eq!(restored.backend_label(), Some("fastembed"));
6803        assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
6804    }
6805
6806    #[test]
6807    fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
6808        let storage = tempfile::tempdir().expect("create storage dir");
6809        let project = storage.path().join("project");
6810        fs::create_dir_all(project.join("src")).expect("create project src");
6811        let file = project.join("src/lib.rs");
6812        fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
6813        let project_root = fs::canonicalize(&project).expect("canonical project");
6814        let file = fs::canonicalize(&file).expect("canonical file");
6815
6816        let mut index = SemanticIndex::new(project_root.clone(), 3);
6817        let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
6818        index.file_mtimes.insert(file.clone(), mtime);
6819        index.file_sizes.insert(file.clone(), 42);
6820        index
6821            .file_hashes
6822            .insert(file.clone(), cache_freshness::zero_hash());
6823        index.entries.push(EmbeddingEntry {
6824            chunk: SemanticChunk {
6825                file: file.clone(),
6826                name: "alpha".to_string(),
6827                qualified_name: Some("Service.alpha".to_string()),
6828                kind: SymbolKind::Function,
6829                start_line: 0,
6830                end_line: 0,
6831                exported: true,
6832                embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
6833                snippet: "pub fn alpha() {}".to_string(),
6834            },
6835            norm: vector_norm(&[0.1, 0.2, 0.3]),
6836            vector: vec![0.1, 0.2, 0.3],
6837        });
6838        index.entries.push(EmbeddingEntry {
6839            chunk: SemanticChunk {
6840                file: file.clone(),
6841                name: "beta".to_string(),
6842                qualified_name: Some("Service.beta".to_string()),
6843                kind: SymbolKind::Function,
6844                start_line: 1,
6845                end_line: 1,
6846                exported: true,
6847                embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
6848                snippet: "pub fn beta() {}".to_string(),
6849            },
6850            norm: vector_norm(&[0.4, 0.5, 0.6]),
6851            vector: vec![0.4, 0.5, 0.6],
6852        });
6853        let fingerprint = SemanticIndexFingerprint {
6854            backend: "fastembed".to_string(),
6855            model: "all-MiniLM-L6-v2".to_string(),
6856            base_url: FALLBACK_BACKEND.to_string(),
6857            dimension: 3,
6858            chunking_version: default_chunking_version(),
6859            ..Default::default()
6860        };
6861        let fingerprint_before = fingerprint.as_string();
6862        index.set_fingerprint(fingerprint.clone());
6863
6864        let legacy_bytes = legacy_semantic_index_bytes(&index);
6865        assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
6866        let legacy_dir = storage.path().join("semantic/legacy-proj");
6867        fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
6868        let legacy_path = legacy_dir.join("semantic.bin");
6869        fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
6870        let legacy_loaded = SemanticIndex::read_from_disk(
6871            storage.path(),
6872            "legacy-proj",
6873            &project_root,
6874            false,
6875            Some(&fingerprint_before),
6876        )
6877        .expect("load v6 semantic index");
6878        assert!(
6879            legacy_path.exists(),
6880            "compatible V6 cache must not be deleted"
6881        );
6882        assert!(legacy_loaded
6883            .entries
6884            .iter()
6885            .all(|entry| entry.chunk.qualified_name.is_none()));
6886        assert_eq!(
6887            legacy_loaded.fingerprint().unwrap().as_string(),
6888            fingerprint_before
6889        );
6890
6891        let v7_bytes = index.to_bytes();
6892        assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
6893        assert_ne!(v7_bytes, legacy_bytes);
6894        let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
6895        assert_eq!(
6896            restored.entries[0].chunk.qualified_name.as_deref(),
6897            Some("Service.alpha")
6898        );
6899        assert_eq!(
6900            restored.entries[1].chunk.qualified_name.as_deref(),
6901            Some("Service.beta")
6902        );
6903        assert_eq!(
6904            restored.fingerprint().unwrap().as_string(),
6905            fingerprint_before
6906        );
6907
6908        index.write_to_disk(storage.path(), "proj");
6909        let data_path = storage.path().join("semantic/proj/semantic.bin");
6910        let persisted = fs::read(&data_path).expect("read semantic.bin");
6911        assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
6912
6913        let loaded = SemanticIndex::read_from_disk(
6914            storage.path(),
6915            "proj",
6916            &project_root,
6917            false,
6918            Some(&fingerprint_before),
6919        )
6920        .expect("load semantic index");
6921        assert_eq!(loaded.entries.len(), index.entries.len());
6922        assert_eq!(loaded.dimension, index.dimension);
6923        assert_eq!(
6924            loaded.fingerprint().unwrap().as_string(),
6925            fingerprint_before
6926        );
6927        assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
6928        assert_eq!(loaded.file_sizes.get(&file), Some(&42));
6929        assert_eq!(
6930            loaded.file_hashes.get(&file),
6931            Some(&cache_freshness::zero_hash())
6932        );
6933        for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
6934            assert_eq!(actual.chunk.file, expected.chunk.file);
6935            assert_eq!(actual.chunk.name, expected.chunk.name);
6936            assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
6937            assert_eq!(actual.chunk.kind, expected.chunk.kind);
6938            assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
6939            assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
6940            assert_eq!(actual.chunk.exported, expected.chunk.exported);
6941            assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
6942            assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
6943            assert_eq!(actual.vector, expected.vector);
6944        }
6945        assert_eq!(loaded.to_bytes(), persisted);
6946        assert_eq!(fingerprint.as_string(), fingerprint_before);
6947    }
6948
6949    #[test]
6950    fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
6951        let cases = [
6952            (SymbolKind::Function, 0),
6953            (SymbolKind::Class, 1),
6954            (SymbolKind::Method, 2),
6955            (SymbolKind::Struct, 3),
6956            (SymbolKind::Interface, 4),
6957            (SymbolKind::Enum, 5),
6958            (SymbolKind::TypeAlias, 6),
6959            (SymbolKind::Variable, 7),
6960            (SymbolKind::Heading, 8),
6961            (SymbolKind::FileSummary, 9),
6962        ];
6963
6964        for (kind, encoded) in cases {
6965            assert_eq!(symbol_kind_to_u8(&kind), encoded);
6966            assert_eq!(u8_to_symbol_kind(encoded), kind);
6967        }
6968    }
6969
6970    #[test]
6971    fn test_search_top_k() {
6972        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6973        index.dimension = 3;
6974
6975        // Add entries with known vectors
6976        for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
6977            let mut vec = vec![0.0f32; 3];
6978            vec[i] = 1.0; // orthogonal vectors
6979            index.entries.push(EmbeddingEntry {
6980                chunk: SemanticChunk {
6981                    file: PathBuf::from("/src/lib.rs"),
6982                    name: name.to_string(),
6983                    qualified_name: None,
6984                    kind: SymbolKind::Function,
6985                    start_line: (i * 10 + 1) as u32,
6986                    end_line: (i * 10 + 5) as u32,
6987                    exported: true,
6988                    embed_text: format!("kind:function name:{}", name),
6989                    snippet: format!("fn {}() {{}}", name),
6990                },
6991                norm: vector_norm(&vec),
6992                vector: vec,
6993            });
6994        }
6995
6996        // Query aligned with "auth" (index 0)
6997        let query = vec![0.9, 0.1, 0.0];
6998        let results = index.search(&query, 2);
6999
7000        assert_eq!(results.len(), 2);
7001        assert_eq!(results[0].name, "auth"); // highest score
7002        assert!(results[0].score > results[1].score);
7003    }
7004
7005    #[test]
7006    fn test_empty_index_search() {
7007        let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7008        let results = index.search(&[0.1, 0.2, 0.3], 10);
7009        assert!(results.is_empty());
7010    }
7011
7012    #[test]
7013    fn single_line_symbol_builds_non_empty_snippet() {
7014        let symbol = Symbol {
7015            name: "answer".to_string(),
7016            kind: SymbolKind::Variable,
7017            range: crate::symbols::Range {
7018                start_line: 0,
7019                start_col: 0,
7020                end_line: 0,
7021                end_col: 24,
7022            },
7023            signature: Some("const answer = 42".to_string()),
7024            scope_chain: Vec::new(),
7025            exported: true,
7026            parent: None,
7027        };
7028        let source = "export const answer = 42;\n";
7029
7030        let snippet = build_snippet(&symbol, source);
7031
7032        assert_eq!(snippet, "export const answer = 42;");
7033    }
7034
7035    #[test]
7036    fn optimized_file_chunk_collection_matches_file_parser_path() {
7037        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7038        let file = project_root.join("src/semantic_index.rs");
7039        let source = std::fs::read_to_string(&file).unwrap();
7040
7041        let mut legacy_parser = FileParser::new();
7042        let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
7043        let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
7044
7045        let optimized_chunks = collect_file_chunks(&project_root, &file).unwrap();
7046
7047        assert_eq!(
7048            chunk_fingerprint(&optimized_chunks),
7049            chunk_fingerprint(&legacy_chunks)
7050        );
7051    }
7052
7053    #[test]
7054    fn collect_file_chunks_indexes_java_symbols() {
7055        let dir = tempfile::tempdir().unwrap();
7056        let file = dir.path().join("Greeter.java");
7057        std::fs::write(
7058            &file,
7059            r#"package example;
7060
7061public class Greeter {
7062    public String greet(String name) {
7063        return "Hello, " + name;
7064    }
7065}
7066"#,
7067        )
7068        .unwrap();
7069
7070        let chunks = collect_file_chunks(dir.path(), &file).unwrap();
7071
7072        assert!(
7073            !chunks.is_empty(),
7074            "Java file should produce semantic chunks"
7075        );
7076        assert!(
7077            chunks
7078                .iter()
7079                .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
7080            "Java class symbol should be chunked: {chunks:?}"
7081        );
7082        assert!(
7083            chunks
7084                .iter()
7085                .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
7086            "Java method symbol should be chunked: {chunks:?}"
7087        );
7088    }
7089
7090    fn chunk_fingerprint(
7091        chunks: &[SemanticChunk],
7092    ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
7093        chunks
7094            .iter()
7095            .map(|chunk| {
7096                (
7097                    chunk.name.clone(),
7098                    chunk.kind.clone(),
7099                    chunk.start_line,
7100                    chunk.end_line,
7101                    chunk.exported,
7102                    chunk.embed_text.clone(),
7103                    chunk.snippet.clone(),
7104                )
7105            })
7106            .collect()
7107    }
7108
7109    #[test]
7110    fn collect_file_chunks_skips_oversized_file() {
7111        let dir = tempfile::tempdir().unwrap();
7112        let big = dir.path().join("huge.ts");
7113        // Just over the cap: a valid TS file that would otherwise yield chunks.
7114        let filler = "export const x = 1;\n"
7115            .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
7116        std::fs::write(&big, &filler).unwrap();
7117        assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
7118
7119        // Oversized → tracked with zero chunks, NOT an error (so the caller keeps
7120        // the file in metadata and freshness skips re-reading it).
7121        let chunks = collect_file_chunks(dir.path(), &big).unwrap();
7122        assert!(chunks.is_empty(), "oversized file must yield no chunks");
7123
7124        // A small file of the same language still produces chunks.
7125        let small = dir.path().join("small.ts");
7126        std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
7127        let small_chunks = collect_file_chunks(dir.path(), &small).unwrap();
7128        assert!(!small_chunks.is_empty(), "small file should still chunk");
7129    }
7130
7131    #[test]
7132    fn rejects_oversized_dimension_during_deserialization() {
7133        let mut bytes = Vec::new();
7134        bytes.push(1u8);
7135        bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
7136        bytes.extend_from_slice(&0u32.to_le_bytes());
7137        bytes.extend_from_slice(&0u32.to_le_bytes());
7138
7139        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
7140    }
7141
7142    #[test]
7143    fn rejects_oversized_entry_count_during_deserialization() {
7144        let mut bytes = Vec::new();
7145        bytes.push(1u8);
7146        bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
7147        bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
7148        bytes.extend_from_slice(&0u32.to_le_bytes());
7149
7150        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
7151    }
7152
7153    fn add_invalidation_fixture_entry(index: &mut SemanticIndex, file: PathBuf, ordinal: u64) {
7154        index.entries.push(EmbeddingEntry::new(
7155            SemanticChunk {
7156                file: file.clone(),
7157                name: format!("symbol_{ordinal}"),
7158                qualified_name: None,
7159                kind: SymbolKind::Function,
7160                start_line: ordinal as u32,
7161                end_line: ordinal as u32 + 1,
7162                exported: false,
7163                embed_text: format!("symbol {ordinal}"),
7164                snippet: format!("fn symbol_{ordinal}() {{}}"),
7165            },
7166            vec![ordinal as f32 + 1.0, 1.0],
7167        ));
7168        let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(ordinal + 1);
7169        index.file_mtimes.insert(file.clone(), mtime);
7170        index.file_sizes.insert(file.clone(), ordinal + 10);
7171        index
7172            .file_hashes
7173            .insert(file, blake3::hash(&ordinal.to_le_bytes()));
7174    }
7175
7176    #[test]
7177    fn batch_invalidation_matches_sequential_calls_with_one_retain_pass() {
7178        let temp = tempfile::tempdir().unwrap();
7179        let project_root = temp.path().canonicalize().unwrap();
7180        let mut source = SemanticIndex::new(project_root.clone(), 2);
7181        let files = (0..8)
7182            .map(|ordinal| {
7183                let file = project_root.join(format!("file_{ordinal}.rs"));
7184                fs::write(&file, format!("fn symbol_{ordinal}() {{}}\n")).unwrap();
7185                add_invalidation_fixture_entry(&mut source, file.clone(), ordinal);
7186                file
7187            })
7188            .collect::<Vec<_>>();
7189        let invalidated = vec![files[1].clone(), files[3].clone(), files[6].clone()];
7190
7191        let shared = Arc::new(source.into_shared_base().unwrap());
7192        let mut shared_batched =
7193            SemanticIndex::from_shared_base(project_root.clone(), Arc::clone(&shared));
7194        shared_batched.invalidate_files(&invalidated);
7195        let mut source = SemanticIndex::from_shared_base(project_root, shared);
7196        source.materialize_shared_base();
7197        let mut sequential = source.clone();
7198        let mut batched = source;
7199        for file in &invalidated {
7200            sequential.invalidate_file(file);
7201        }
7202        batched.invalidate_files(&invalidated);
7203
7204        assert!(sequential.shared_base.is_none());
7205        assert!(batched.shared_base.is_none());
7206        assert!(shared_batched.shared_base.is_none());
7207        assert_eq!(batched.to_bytes(), sequential.to_bytes());
7208        assert_eq!(shared_batched.file_mtimes, batched.file_mtimes);
7209        assert_eq!(shared_batched.file_sizes, batched.file_sizes);
7210        assert_eq!(shared_batched.file_hashes, batched.file_hashes);
7211        assert_eq!(
7212            format!("{:?}", shared_batched.entries),
7213            format!("{:?}", batched.entries)
7214        );
7215        assert_eq!(
7216            sequential.removal_retain_passes_for_test(),
7217            invalidated.len()
7218        );
7219        assert_eq!(batched.removal_retain_passes_for_test(), 1);
7220        assert_eq!(shared_batched.removal_retain_passes_for_test(), 1);
7221    }
7222
7223    #[cfg(unix)]
7224    #[test]
7225    fn batch_invalidation_removes_raw_and_canonical_alias_metadata() {
7226        use std::os::unix::fs::symlink;
7227
7228        let temp = tempfile::tempdir().unwrap();
7229        let project_root = temp.path().canonicalize().unwrap();
7230        let real_dir = project_root.join("real");
7231        let alias_dir = project_root.join("alias");
7232        fs::create_dir(&real_dir).unwrap();
7233        symlink(&real_dir, &alias_dir).unwrap();
7234        let real_file = real_dir.join("lib.rs");
7235        let alias_file = alias_dir.join("lib.rs");
7236        let untouched = project_root.join("untouched.rs");
7237        fs::write(&real_file, "fn aliased() {}\n").unwrap();
7238        fs::write(&untouched, "fn untouched() {}\n").unwrap();
7239        assert_eq!(fs::canonicalize(&alias_file).unwrap(), real_file);
7240
7241        let mut index = SemanticIndex::new(project_root, 2);
7242        add_invalidation_fixture_entry(&mut index, alias_file.clone(), 1);
7243        add_invalidation_fixture_entry(&mut index, real_file.clone(), 2);
7244        add_invalidation_fixture_entry(&mut index, untouched.clone(), 3);
7245        let mut sequential = index.clone();
7246        sequential.invalidate_file(&alias_file);
7247        index.invalidate_files(std::slice::from_ref(&alias_file));
7248
7249        assert_eq!(index.to_bytes(), sequential.to_bytes());
7250        assert!(index
7251            .entries
7252            .iter()
7253            .all(|entry| entry.chunk.file != alias_file && entry.chunk.file != real_file));
7254        assert!(!index.file_mtimes.contains_key(&alias_file));
7255        assert!(!index.file_mtimes.contains_key(&real_file));
7256        assert!(index.file_mtimes.contains_key(&untouched));
7257        assert!(!index.file_sizes.contains_key(&alias_file));
7258        assert!(!index.file_sizes.contains_key(&real_file));
7259        assert!(index.file_sizes.contains_key(&untouched));
7260        assert!(!index.file_hashes.contains_key(&alias_file));
7261        assert!(!index.file_hashes.contains_key(&real_file));
7262        assert!(index.file_hashes.contains_key(&untouched));
7263        assert_eq!(index.removal_retain_passes_for_test(), 1);
7264    }
7265
7266    #[test]
7267    fn invalidate_file_removes_entries_and_mtime() {
7268        let target = PathBuf::from("/src/main.rs");
7269        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7270        index.entries.push(EmbeddingEntry {
7271            chunk: SemanticChunk {
7272                file: target.clone(),
7273                name: "main".to_string(),
7274                qualified_name: None,
7275                kind: SymbolKind::Function,
7276                start_line: 0,
7277                end_line: 1,
7278                exported: false,
7279                embed_text: "main".to_string(),
7280                snippet: "fn main() {}".to_string(),
7281            },
7282            norm: vector_norm(&[1.0; DEFAULT_DIMENSION]),
7283            vector: vec![1.0; DEFAULT_DIMENSION],
7284        });
7285        index
7286            .file_mtimes
7287            .insert(target.clone(), SystemTime::UNIX_EPOCH);
7288        index.file_sizes.insert(target.clone(), 0);
7289
7290        index.invalidate_file(&target);
7291
7292        assert!(index.entries.is_empty());
7293        assert!(!index.file_mtimes.contains_key(&target));
7294        assert!(!index.file_sizes.contains_key(&target));
7295    }
7296
7297    #[test]
7298    fn refresh_missing_changed_file_is_purged_after_collect() {
7299        let temp = tempfile::tempdir().unwrap();
7300        let project_root = temp.path();
7301        let file = project_root.join("src/lib.rs");
7302        fs::create_dir_all(file.parent().unwrap()).unwrap();
7303        write_rust_file(&file, "vanished_symbol");
7304
7305        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7306        let original_size = *index.file_sizes.get(&file).unwrap();
7307        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
7308        fs::remove_file(&file).unwrap();
7309
7310        let mut embed = test_vector_for_texts;
7311        let mut progress = |_done: usize, _total: usize| {};
7312        let summary = index
7313            .refresh_stale_files(
7314                project_root,
7315                std::slice::from_ref(&file),
7316                &mut embed,
7317                8,
7318                &mut progress,
7319            )
7320            .unwrap();
7321
7322        assert_eq!(summary.changed, 0);
7323        assert_eq!(summary.added, 0);
7324        assert_eq!(summary.deleted, 1);
7325        assert!(index.entries.is_empty());
7326        assert!(!index.file_mtimes.contains_key(&file));
7327        assert!(!index.file_sizes.contains_key(&file));
7328        assert!(!index.file_hashes.contains_key(&file));
7329    }
7330
7331    #[test]
7332    fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
7333        let temp = tempfile::tempdir().unwrap();
7334        let project_root = temp.path();
7335        let file = project_root.join("src/lib.rs");
7336        fs::create_dir_all(file.parent().unwrap()).unwrap();
7337        write_rust_file(&file, "kept_symbol");
7338
7339        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7340        let original_entry_count = index.entries.len();
7341        let original_mtime = *index.file_mtimes.get(&file).unwrap();
7342        let original_size = *index.file_sizes.get(&file).unwrap();
7343
7344        let stale_mtime = SystemTime::UNIX_EPOCH;
7345        set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
7346        fs::remove_file(&file).unwrap();
7347        fs::create_dir(&file).unwrap();
7348
7349        let mut embed = test_vector_for_texts;
7350        let mut progress = |_done: usize, _total: usize| {};
7351        let summary = index
7352            .refresh_stale_files(
7353                project_root,
7354                std::slice::from_ref(&file),
7355                &mut embed,
7356                8,
7357                &mut progress,
7358            )
7359            .unwrap();
7360
7361        assert_eq!(summary.changed, 0);
7362        assert_eq!(summary.added, 0);
7363        assert_eq!(summary.deleted, 0);
7364        assert_eq!(index.entries.len(), original_entry_count);
7365        assert!(index
7366            .entries
7367            .iter()
7368            .any(|entry| entry.chunk.name == "kept_symbol"));
7369        assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
7370        assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
7371        assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
7372    }
7373
7374    #[test]
7375    fn refresh_never_indexed_file_error_does_not_record_mtime() {
7376        let temp = tempfile::tempdir().unwrap();
7377        let project_root = temp.path();
7378        let missing = project_root.join("src/missing.rs");
7379        fs::create_dir_all(missing.parent().unwrap()).unwrap();
7380
7381        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7382        let mut embed = test_vector_for_texts;
7383        let mut progress = |_done: usize, _total: usize| {};
7384        let summary = index
7385            .refresh_stale_files(
7386                project_root,
7387                std::slice::from_ref(&missing),
7388                &mut embed,
7389                8,
7390                &mut progress,
7391            )
7392            .unwrap();
7393
7394        assert_eq!(summary.added, 0);
7395        assert_eq!(summary.changed, 0);
7396        assert_eq!(summary.deleted, 0);
7397        assert!(!index.file_mtimes.contains_key(&missing));
7398        assert!(!index.file_sizes.contains_key(&missing));
7399        assert!(index.entries.is_empty());
7400    }
7401
7402    #[test]
7403    fn refresh_reports_added_for_new_files() {
7404        let temp = tempfile::tempdir().unwrap();
7405        let project_root = temp.path();
7406        let existing = project_root.join("src/lib.rs");
7407        let added = project_root.join("src/new.rs");
7408        fs::create_dir_all(existing.parent().unwrap()).unwrap();
7409        write_rust_file(&existing, "existing_symbol");
7410        write_rust_file(&added, "added_symbol");
7411
7412        let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
7413        let mut embed = test_vector_for_texts;
7414        let mut progress = |_done: usize, _total: usize| {};
7415        let summary = index
7416            .refresh_stale_files(
7417                project_root,
7418                &[existing.clone(), added.clone()],
7419                &mut embed,
7420                8,
7421                &mut progress,
7422            )
7423            .unwrap();
7424
7425        assert_eq!(summary.added, 1);
7426        assert_eq!(summary.changed, 0);
7427        assert_eq!(summary.deleted, 0);
7428        assert_eq!(summary.total_processed, 2);
7429        assert!(index.file_mtimes.contains_key(&added));
7430        assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
7431    }
7432
7433    #[test]
7434    fn refresh_reports_deleted_for_removed_files() {
7435        let temp = tempfile::tempdir().unwrap();
7436        let project_root = temp.path();
7437        let deleted = project_root.join("src/deleted.rs");
7438        fs::create_dir_all(deleted.parent().unwrap()).unwrap();
7439        write_rust_file(&deleted, "deleted_symbol");
7440
7441        let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
7442        fs::remove_file(&deleted).unwrap();
7443
7444        let mut embed = test_vector_for_texts;
7445        let mut progress = |_done: usize, _total: usize| {};
7446        let summary = index
7447            .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
7448            .unwrap();
7449
7450        assert_eq!(summary.deleted, 1);
7451        assert_eq!(summary.changed, 0);
7452        assert_eq!(summary.added, 0);
7453        assert_eq!(summary.total_processed, 1);
7454        assert!(!index.file_mtimes.contains_key(&deleted));
7455        assert!(index.entries.is_empty());
7456    }
7457
7458    #[test]
7459    fn refresh_reports_changed_for_modified_files() {
7460        let temp = tempfile::tempdir().unwrap();
7461        let project_root = temp.path();
7462        let file = project_root.join("src/lib.rs");
7463        fs::create_dir_all(file.parent().unwrap()).unwrap();
7464        write_rust_file(&file, "old_symbol");
7465
7466        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7467        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
7468        write_rust_file(&file, "new_symbol");
7469
7470        let mut embed = test_vector_for_texts;
7471        let mut progress = |_done: usize, _total: usize| {};
7472        let summary = index
7473            .refresh_stale_files(
7474                project_root,
7475                std::slice::from_ref(&file),
7476                &mut embed,
7477                8,
7478                &mut progress,
7479            )
7480            .unwrap();
7481
7482        assert_eq!(summary.changed, 1);
7483        assert_eq!(summary.added, 0);
7484        assert_eq!(summary.deleted, 0);
7485        assert_eq!(summary.total_processed, 1);
7486        assert!(index
7487            .entries
7488            .iter()
7489            .any(|entry| entry.chunk.name == "new_symbol"));
7490        assert!(!index
7491            .entries
7492            .iter()
7493            .any(|entry| entry.chunk.name == "old_symbol"));
7494    }
7495
7496    #[test]
7497    fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
7498        let temp = tempfile::tempdir().unwrap();
7499        let project_root = temp.path();
7500        let file = project_root.join("src/lib.rs");
7501        fs::create_dir_all(file.parent().unwrap()).unwrap();
7502        write_rust_file(&file, "clean_symbol");
7503
7504        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
7505        let original_entries = index.entries.len();
7506        let mut embed_called = false;
7507        let mut embed = |texts: Vec<String>| {
7508            embed_called = true;
7509            test_vector_for_texts(texts)
7510        };
7511        let mut progress = |_done: usize, _total: usize| {};
7512        let summary = index
7513            .refresh_stale_files(
7514                project_root,
7515                std::slice::from_ref(&file),
7516                &mut embed,
7517                8,
7518                &mut progress,
7519            )
7520            .unwrap();
7521
7522        assert!(summary.is_noop());
7523        assert_eq!(summary.total_processed, 1);
7524        assert!(!embed_called);
7525        assert_eq!(index.entries.len(), original_entries);
7526    }
7527
7528    #[test]
7529    fn detects_missing_onnx_runtime_from_dynamic_load_error() {
7530        let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
7531
7532        assert!(is_onnx_runtime_unavailable(message));
7533    }
7534
7535    #[test]
7536    fn formats_missing_onnx_runtime_with_install_hint() {
7537        let message = format_embedding_init_error(
7538            "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
7539        );
7540
7541        assert!(message.starts_with("ONNX Runtime not found. Install via:"));
7542        assert!(message.contains("Original error:"));
7543    }
7544
7545    #[test]
7546    fn interactive_query_budget_is_independent_from_build_timeout() {
7547        let mut config = SemanticBackendConfig {
7548            backend: SemanticBackend::OpenAiCompatible,
7549            model: "test-embedding".to_string(),
7550            base_url: Some("http://127.0.0.1:9".to_string()),
7551            api_key_env: None,
7552            timeout_ms: 0,
7553            query_timeout_ms: 0,
7554            max_batch_size: 64,
7555            max_files: 20_000,
7556            ..Default::default()
7557        };
7558
7559        let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
7560        let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
7561        assert_eq!(
7562            build_model.timeout_ms(),
7563            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
7564            "background build keeps the longer default embedding timeout"
7565        );
7566        assert_eq!(
7567            query_model.timeout_ms(),
7568            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
7569            "a query-created model remains safe for later background build reuse"
7570        );
7571        assert_eq!(
7572            QueryBudget::from_config(&config).timeout_ms(),
7573            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
7574        );
7575
7576        config.timeout_ms = 60_000;
7577        assert_eq!(
7578            QueryBudget::from_config(&config).timeout_ms(),
7579            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7580            "the build timeout must not affect interactive requests"
7581        );
7582
7583        config.query_timeout_ms = 700;
7584        assert_eq!(QueryBudget::from_config(&config).timeout_ms(), 700);
7585    }
7586
7587    #[test]
7588    fn background_build_embedding_keeps_retry_ladder() {
7589        let (base_url, requests, handle) =
7590            start_slow_embedding_server(EMBEDDING_REQUEST_MAX_ATTEMPTS, Duration::from_millis(300));
7591        let config = SemanticBackendConfig {
7592            backend: SemanticBackend::OpenAiCompatible,
7593            model: "test-embedding".to_string(),
7594            base_url: Some(base_url),
7595            api_key_env: None,
7596            timeout_ms: 100,
7597            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7598            max_batch_size: 64,
7599            max_files: 20_000,
7600            ..Default::default()
7601        };
7602        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7603
7604        let error = model
7605            .embed(vec!["slow build batch".to_string()])
7606            .expect_err("all slow build attempts should time out");
7607        handle.join().expect("slow embedding server");
7608
7609        assert!(embedding_failure_is_transient(&error), "error: {error}");
7610        assert_eq!(
7611            requests.load(Ordering::SeqCst),
7612            EMBEDDING_REQUEST_MAX_ATTEMPTS,
7613            "background builds must retain the existing retry ladder"
7614        );
7615    }
7616
7617    #[test]
7618    fn openai_compatible_backend_embeds_with_mock_server() {
7619        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
7620            assert!(request_line.starts_with("POST "));
7621            assert_eq!(path, "/v1/embeddings");
7622            "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
7623        });
7624
7625        let config = SemanticBackendConfig {
7626            backend: SemanticBackend::OpenAiCompatible,
7627            model: "test-embedding".to_string(),
7628            base_url: Some(base_url),
7629            api_key_env: None,
7630            timeout_ms: 5_000,
7631            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7632            max_batch_size: 64,
7633            max_files: 20_000,
7634            ..Default::default()
7635        };
7636
7637        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7638        let vectors = model
7639            .embed(vec!["hello".to_string(), "world".to_string()])
7640            .unwrap();
7641
7642        assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
7643        handle.join().unwrap();
7644    }
7645
7646    /// Regression for issue #36: AFT was sending TWO Content-Type headers
7647    /// on the OpenAI embeddings request — once implicitly via `.json(&body)`
7648    /// and again explicitly via `.header("Content-Type", "application/json")`.
7649    /// reqwest's `.header()` calls `HeaderMap::append`, which produces two
7650    /// headers on the wire. OpenAI's /v1/embeddings endpoint rejects that
7651    /// with `HTTP 400 "you must provide a model parameter"` even though the
7652    /// body actually contains `model`. The fix is to drop the explicit
7653    /// `.header("Content-Type", ...)` call. This test pins that we send
7654    /// exactly one Content-Type header.
7655    #[test]
7656    fn openai_compatible_request_has_single_content_type_header() {
7657        use std::sync::{Arc, Mutex};
7658        let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
7659        let captured_for_thread = Arc::clone(&captured);
7660
7661        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
7662        let addr = listener.local_addr().expect("local addr");
7663        let handle = thread::spawn(move || {
7664            let (mut stream, _) = listener.accept().expect("accept");
7665            let mut buf = Vec::new();
7666            let mut chunk = [0u8; 4096];
7667            let mut header_end = None;
7668            let mut content_length = 0usize;
7669            loop {
7670                let n = stream.read(&mut chunk).expect("read");
7671                if n == 0 {
7672                    break;
7673                }
7674                buf.extend_from_slice(&chunk[..n]);
7675                if header_end.is_none() {
7676                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
7677                        header_end = Some(pos + 4);
7678                        for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
7679                            if let Some(value) = line.strip_prefix("Content-Length:") {
7680                                content_length = value.trim().parse::<usize>().unwrap_or(0);
7681                            }
7682                        }
7683                    }
7684                }
7685                if let Some(end) = header_end {
7686                    if buf.len() >= end + content_length {
7687                        break;
7688                    }
7689                }
7690            }
7691            *captured_for_thread.lock().unwrap() = buf;
7692            let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
7693            let response = format!(
7694                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7695                body.len(),
7696                body
7697            );
7698            let _ = stream.write_all(response.as_bytes());
7699        });
7700
7701        let config = SemanticBackendConfig {
7702            backend: SemanticBackend::OpenAiCompatible,
7703            model: "text-embedding-3-small".to_string(),
7704            base_url: Some(format!("http://{}", addr)),
7705            api_key_env: None,
7706            timeout_ms: 5_000,
7707            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7708            max_batch_size: 64,
7709            max_files: 20_000,
7710            ..Default::default()
7711        };
7712        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7713        let _ = model.embed(vec!["probe".to_string()]).unwrap();
7714        handle.join().unwrap();
7715
7716        let bytes = captured.lock().unwrap().clone();
7717        let request = String::from_utf8_lossy(&bytes);
7718
7719        // Lowercase line counts because HTTP headers are case-insensitive
7720        // and reqwest may emit `content-type` in lowercase under HTTP/2.
7721        let content_type_lines = request
7722            .lines()
7723            .filter(|line| {
7724                let lower = line.to_ascii_lowercase();
7725                lower.starts_with("content-type:")
7726            })
7727            .count();
7728        assert_eq!(
7729            content_type_lines, 1,
7730            "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
7731        );
7732
7733        // The body must still include the model field — pin this so a future
7734        // change can't accidentally drop `model` while fixing duplicate headers.
7735        assert!(
7736            request.contains(r#""model":"text-embedding-3-small""#),
7737            "request body should contain model field; full request:\n{request}",
7738        );
7739    }
7740
7741    #[test]
7742    fn ollama_backend_embeds_with_mock_server() {
7743        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
7744            assert!(request_line.starts_with("POST "));
7745            assert_eq!(path, "/api/embed");
7746            "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
7747        });
7748
7749        let config = SemanticBackendConfig {
7750            backend: SemanticBackend::Ollama,
7751            model: "embeddinggemma".to_string(),
7752            base_url: Some(base_url),
7753            api_key_env: None,
7754            timeout_ms: 5_000,
7755            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
7756            max_batch_size: 64,
7757            max_files: 20_000,
7758            ..Default::default()
7759        };
7760
7761        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
7762        let vectors = model
7763            .embed(vec!["hello".to_string(), "world".to_string()])
7764            .unwrap();
7765
7766        assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
7767        handle.join().unwrap();
7768    }
7769
7770    #[test]
7771    fn read_from_disk_rejects_fingerprint_mismatch() {
7772        let storage = tempfile::tempdir().unwrap();
7773        let project_key = "proj";
7774
7775        let project_root = test_project_root();
7776        let file = project_root.join("src/main.rs");
7777        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
7778        index.entries.push(EmbeddingEntry {
7779            chunk: SemanticChunk {
7780                file: file.clone(),
7781                name: "handle_request".to_string(),
7782                qualified_name: None,
7783                kind: SymbolKind::Function,
7784                start_line: 10,
7785                end_line: 25,
7786                exported: true,
7787                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
7788                snippet: "fn handle_request() {}".to_string(),
7789            },
7790            norm: vector_norm(&[0.1, 0.2, 0.3]),
7791            vector: vec![0.1, 0.2, 0.3],
7792        });
7793        index.dimension = 3;
7794        index
7795            .file_mtimes
7796            .insert(file.clone(), SystemTime::UNIX_EPOCH);
7797        index.file_sizes.insert(file, 0);
7798        index.set_fingerprint(SemanticIndexFingerprint {
7799            backend: "openai_compatible".to_string(),
7800            model: "test-embedding".to_string(),
7801            base_url: "http://127.0.0.1:1234/v1".to_string(),
7802            dimension: 3,
7803            chunking_version: default_chunking_version(),
7804            ..Default::default()
7805        });
7806        index.write_to_disk(storage.path(), project_key);
7807
7808        let data_path = storage
7809            .path()
7810            .join("semantic")
7811            .join(project_key)
7812            .join("semantic.bin");
7813        let before = fs::read(&data_path).unwrap();
7814
7815        let matching = index.fingerprint().unwrap().as_string();
7816        assert!(SemanticIndex::read_from_disk(
7817            storage.path(),
7818            project_key,
7819            &project_root,
7820            false,
7821            Some(&matching),
7822        )
7823        .is_some());
7824
7825        let mismatched = SemanticIndexFingerprint {
7826            backend: "ollama".to_string(),
7827            model: "embeddinggemma".to_string(),
7828            base_url: "http://127.0.0.1:11434".to_string(),
7829            dimension: 3,
7830            chunking_version: default_chunking_version(),
7831            ..Default::default()
7832        }
7833        .as_string();
7834        assert!(SemanticIndex::read_from_disk(
7835            storage.path(),
7836            project_key,
7837            &project_root,
7838            false,
7839            Some(&mismatched),
7840        )
7841        .is_none());
7842        assert_eq!(fs::read(&data_path).unwrap(), before);
7843    }
7844
7845    #[test]
7846    fn synapse_fingerprint_pin_matches_only_equivalent_alias_at_same_epoch() {
7847        let cached = SemanticIndexFingerprint {
7848            backend: "synapse".to_string(),
7849            model: "configured-model".to_string(),
7850            dimension: 768,
7851            chunking_version: 2,
7852            synapse_fingerprint: Some("fp-old".to_string()),
7853            synapse_table_epoch: Some(9),
7854            ..Default::default()
7855        };
7856        let mut served = cached.clone();
7857        served.synapse_fingerprint = Some("fp-current".to_string());
7858        served.synapse_equivalent_to = vec!["fp-old".to_string()];
7859        assert!(cached.matches_expected(&served.as_string()));
7860
7861        served.synapse_table_epoch = Some(10);
7862        assert!(!cached.matches_expected(&served.as_string()));
7863    }
7864
7865    #[test]
7866    fn fingerprint_mismatch_details_redact_base_url_and_list_changed_fields() {
7867        let cached = SemanticIndexFingerprint {
7868            backend: "openai_compatible".to_string(),
7869            model: "cached-model".to_string(),
7870            base_url: "https://user:secret@example.com/v1/embeddings".to_string(),
7871            dimension: 3,
7872            chunking_version: 2,
7873            ..Default::default()
7874        };
7875        let current = SemanticIndexFingerprint {
7876            backend: "ollama".to_string(),
7877            model: "current-model".to_string(),
7878            base_url: "https://example.org/api/embed".to_string(),
7879            dimension: 4,
7880            chunking_version: 3,
7881            ..Default::default()
7882        };
7883
7884        let details = format_fingerprint_mismatch_details(Some(&cached), &current);
7885
7886        assert!(details.contains("backend kind cached=openai_compatible current=ollama"));
7887        assert!(details.contains("model cached=cached-model current=current-model"));
7888        assert!(details.contains("base_url host cached=example.com current=example.org"));
7889        assert!(details.contains("dimension cached=3 current=4"));
7890        assert!(details.contains("chunking version cached=2 current=3"));
7891        assert!(!details.contains("secret"));
7892        assert!(!details.contains("/v1/embeddings"));
7893        assert!(!details.contains("/api/embed"));
7894    }
7895
7896    #[test]
7897    fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
7898        let storage = tempfile::tempdir().unwrap();
7899        let project_key = "proj-v3";
7900        let dir = storage.path().join("semantic").join(project_key);
7901        fs::create_dir_all(&dir).unwrap();
7902
7903        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
7904        index.entries.push(EmbeddingEntry {
7905            chunk: SemanticChunk {
7906                file: PathBuf::from("/src/main.rs"),
7907                name: "handle_request".to_string(),
7908                qualified_name: None,
7909                kind: SymbolKind::Function,
7910                start_line: 0,
7911                end_line: 0,
7912                exported: true,
7913                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
7914                snippet: "fn handle_request() {}".to_string(),
7915            },
7916            norm: vector_norm(&[0.1, 0.2, 0.3]),
7917            vector: vec![0.1, 0.2, 0.3],
7918        });
7919        index.dimension = 3;
7920        index
7921            .file_mtimes
7922            .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
7923        index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
7924        let fingerprint = SemanticIndexFingerprint {
7925            backend: "fastembed".to_string(),
7926            model: "test".to_string(),
7927            base_url: FALLBACK_BACKEND.to_string(),
7928            dimension: 3,
7929            chunking_version: default_chunking_version(),
7930            ..Default::default()
7931        };
7932        index.set_fingerprint(fingerprint.clone());
7933
7934        let mut bytes = index.to_bytes();
7935        bytes[0] = SEMANTIC_INDEX_VERSION_V3;
7936        let data_path = dir.join("semantic.bin");
7937        fs::write(&data_path, &bytes).unwrap();
7938
7939        assert!(SemanticIndex::read_from_disk(
7940            storage.path(),
7941            project_key,
7942            &test_project_root(),
7943            false,
7944            Some(&fingerprint.as_string())
7945        )
7946        .is_none());
7947        assert_eq!(fs::read(&data_path).unwrap(), bytes);
7948    }
7949
7950    fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
7951        crate::symbols::Symbol {
7952            name: name.to_string(),
7953            kind,
7954            range: crate::symbols::Range {
7955                start_line: start,
7956                start_col: 0,
7957                end_line: end,
7958                end_col: 0,
7959            },
7960            signature: None,
7961            scope_chain: Vec::new(),
7962            exported: false,
7963            parent: None,
7964        }
7965    }
7966
7967    #[test]
7968    fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
7969        let project_root = PathBuf::from("/proj");
7970        let file = project_root.join("src/engine.ts");
7971        let source = "class Index {\n}\n";
7972        let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
7973        symbol.scope_chain = vec!["Engine".to_string()];
7974        symbol.signature = Some("class Index".to_string());
7975        let embed_text = build_embed_text(&symbol, source, &file, &project_root);
7976
7977        let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
7978        let chunk = chunks
7979            .iter()
7980            .find(|chunk| chunk.name == "Index")
7981            .expect("class chunk");
7982
7983        assert_eq!(chunk.name, "Index");
7984        assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
7985        assert_eq!(chunk.embed_text, embed_text);
7986        assert!(!chunk.embed_text.contains("Engine.Index"));
7987    }
7988
7989    /// Heading symbols (Markdown / HTML headings) must NOT be indexed —
7990    /// they overwhelmingly dominated semantic results even on code-shaped
7991    /// queries because heading prose embeds far more strongly than code
7992    /// chunks. Skipping headings keeps aft_search a code-finder.
7993    #[test]
7994    fn symbols_to_chunks_skips_heading_symbols() {
7995        let project_root = PathBuf::from("/proj");
7996        let file = project_root.join("README.md");
7997        let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
7998
7999        let symbols = vec![
8000            make_symbol(SymbolKind::Heading, "Title", 0, 2),
8001            make_symbol(SymbolKind::Heading, "Section", 4, 6),
8002        ];
8003
8004        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
8005        assert!(
8006            chunks.is_empty(),
8007            "Heading symbols must be filtered out before embedding; got {} chunk(s)",
8008            chunks.len()
8009        );
8010    }
8011
8012    /// A symbol with an enormous signature (e.g. a YAML/Kubernetes CronJob
8013    /// whose inline `command:` script is parsed into the signature) must not
8014    /// produce an embed_text that overflows the embedding backend's physical
8015    /// batch. Before the clamp, the unbounded `signature:` append created a
8016    /// multi-KB input that aborted the whole index build and degraded every
8017    /// search to lexical-only.
8018    #[test]
8019    fn build_embed_text_clamps_oversized_signature() {
8020        let project_root = PathBuf::from("/proj");
8021        let file = project_root.join("cronjob.yaml");
8022        let huge_sig = "kubectl ".repeat(2000); // ~16 KB
8023        let source = "apiVersion: batch/v1\nkind: CronJob\n";
8024
8025        let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
8026        symbol.signature = Some(huge_sig);
8027
8028        let text = build_embed_text(&symbol, source, &file, &project_root);
8029        assert!(
8030            text.chars().count() <= MAX_EMBED_TEXT_CHARS,
8031            "embed_text must be clamped to {} chars, got {}",
8032            MAX_EMBED_TEXT_CHARS,
8033            text.chars().count()
8034        );
8035    }
8036
8037    /// Code symbols (functions, classes, methods, structs, etc.) must still
8038    /// be indexed alongside the heading skip — otherwise we'd starve the
8039    /// index entirely.
8040    #[test]
8041    fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
8042        let project_root = PathBuf::from("/proj");
8043        let file = project_root.join("src/lib.rs");
8044        let source = "pub fn handle_request() -> bool {\n    true\n}\n";
8045
8046        let symbols = vec![
8047            // A heading mixed in (e.g. from a doc comment block elsewhere).
8048            make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
8049            make_symbol(SymbolKind::Function, "handle_request", 0, 2),
8050            make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
8051        ];
8052
8053        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
8054        assert_eq!(
8055            chunks.len(),
8056            3,
8057            "Expected file-summary + 2 code chunks (Function + Struct), got {}",
8058            chunks.len()
8059        );
8060        let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
8061        assert!(chunks
8062            .iter()
8063            .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
8064        assert!(names.contains(&"handle_request"));
8065        assert!(names.contains(&"AuthService"));
8066        assert!(
8067            !names.contains(&"doc heading"),
8068            "Heading symbol leaked into chunks: {names:?}"
8069        );
8070    }
8071
8072    #[test]
8073    fn validate_ssrf_allows_loopback_hostnames() {
8074        // Loopback hostnames are explicitly allowed so self-hosted backends
8075        // (Ollama at http://localhost:11434) work at their default config.
8076        for host in &[
8077            "http://localhost",
8078            "http://localhost:8080",
8079            "http://localhost:11434", // Ollama default
8080            "http://localhost.localdomain",
8081            "http://foo.localhost",
8082        ] {
8083            assert!(
8084                validate_base_url_no_ssrf(host).is_ok(),
8085                "Expected {host} to be allowed (loopback), got: {:?}",
8086                validate_base_url_no_ssrf(host)
8087            );
8088        }
8089    }
8090
8091    #[test]
8092    fn validate_ssrf_allows_loopback_ips() {
8093        // 127.0.0.0/8 is loopback — by definition same-machine and not an
8094        // SSRF target. Allow it so Ollama at http://127.0.0.1:11434 works.
8095        for url in &[
8096            "http://127.0.0.1",
8097            "http://127.0.0.1:11434", // Ollama default
8098            "http://127.0.0.1:8080",
8099            "http://127.1.2.3",
8100        ] {
8101            let result = validate_base_url_no_ssrf(url);
8102            assert!(
8103                result.is_ok(),
8104                "Expected {url} to be allowed (loopback), got: {:?}",
8105                result
8106            );
8107        }
8108    }
8109
8110    #[test]
8111    fn validate_ssrf_rejects_private_non_loopback_ips() {
8112        // Non-loopback private/reserved IPs remain rejected — homelab/intranet
8113        // services on LAN IPs are real SSRF targets even though the user
8114        // configured them. Users who want this can opt in by binding the
8115        // service to a public-routable address.
8116        for url in &[
8117            "http://192.168.1.1",
8118            "http://10.0.0.1",
8119            "http://172.16.0.1",
8120            "http://169.254.169.254",
8121            "http://100.64.0.1",
8122        ] {
8123            let result = validate_base_url_no_ssrf(url);
8124            assert!(
8125                result.is_err(),
8126                "Expected {url} to be rejected (non-loopback private), got: {:?}",
8127                result
8128            );
8129        }
8130    }
8131
8132    #[test]
8133    fn validate_ssrf_rejects_mdns_local_hostnames() {
8134        // mDNS .local hostnames typically resolve to LAN devices, not
8135        // loopback. Rejecting them before DNS lookup gives a clearer error.
8136        for host in &[
8137            "http://printer.local",
8138            "http://nas.local:8080",
8139            "http://homelab.local",
8140        ] {
8141            let result = validate_base_url_no_ssrf(host);
8142            assert!(
8143                result.is_err(),
8144                "Expected {host} to be rejected (mDNS), got: {:?}",
8145                result
8146            );
8147        }
8148    }
8149
8150    #[test]
8151    fn normalize_base_url_allows_localhost_for_tests() {
8152        // normalize_base_url itself should NOT block localhost — only
8153        // validate_base_url_no_ssrf does. Tests construct backends directly.
8154        assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
8155        assert!(normalize_base_url("http://localhost:8080").is_ok());
8156    }
8157
8158    #[test]
8159    fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
8160        use std::net::IpAddr;
8161        let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
8162
8163        // Private / link-local / CGNAT — blocked (unchanged behavior).
8164        assert!(blocked("10.0.0.1"));
8165        assert!(blocked("192.168.1.1"));
8166        assert!(blocked("169.254.0.1"));
8167        assert!(blocked("100.64.0.1"));
8168        // Newly covered by delegating to url_fetch's complete list:
8169        assert!(
8170            blocked("198.18.0.1"),
8171            "RFC2544 benchmark range must be blocked"
8172        );
8173        assert!(blocked("224.0.0.1"), "multicast must be blocked");
8174        assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
8175        assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
8176
8177        // Loopback — allowed (local Ollama endpoint), incl. IPv4-mapped form.
8178        assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
8179        assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
8180        assert!(
8181            !blocked("::ffff:127.0.0.1"),
8182            "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
8183        );
8184
8185        // A public address must NOT be flagged.
8186        assert!(!blocked("8.8.8.8"));
8187    }
8188
8189    /// Pin the user-facing wording of the ONNX version-mismatch error.
8190    /// The auto-fix path MUST be listed first because it's the only safe
8191    /// option that doesn't require sudo or risk breaking other apps that
8192    /// link the system library. Regression of any of these strings would
8193    /// either mislead users (system rm before auto-fix) or break the
8194    /// `aft doctor --fix` discovery path.
8195    #[test]
8196    fn ort_mismatch_message_recommends_auto_fix_first() {
8197        let msg =
8198            format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
8199
8200        // The reported version and path must appear verbatim.
8201        assert!(
8202            msg.contains("v1.9.0"),
8203            "should report detected version: {msg}"
8204        );
8205        assert!(
8206            msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
8207            "should report system path: {msg}"
8208        );
8209        assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
8210
8211        // Solution ordering: auto-fix is #1, system rm is #2, install is #3.
8212        let auto_fix_pos = msg
8213            .find("Auto-fix")
8214            .expect("Auto-fix solution missing — users won't discover --fix");
8215        let remove_pos = msg
8216            .find("Remove the old library")
8217            .expect("system-rm solution missing");
8218        assert!(
8219            auto_fix_pos < remove_pos,
8220            "Auto-fix must come before manual rm — see PR comment thread"
8221        );
8222
8223        // The auto-fix command must be runnable as-is on a fresh system.
8224        assert!(
8225            msg.contains("npx @cortexkit/aft doctor --fix"),
8226            "auto-fix command must be present and copy-pasteable: {msg}"
8227        );
8228    }
8229
8230    #[cfg(any(target_os = "linux", target_os = "macos"))]
8231    #[test]
8232    fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
8233        let requested = "libonnxruntime.so";
8234        let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
8235
8236        assert_eq!(detect_ort_version_from_path(requested), None);
8237        let (version, source) =
8238            detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
8239
8240        assert_eq!(version, Some("1.19.0".to_string()));
8241        assert_eq!(source, actual);
8242
8243        let msg = format_ort_version_mismatch(&version.unwrap(), &source);
8244        assert!(msg.contains("v1.19.0"));
8245        assert!(msg.contains(actual));
8246    }
8247
8248    /// macOS dylib paths must not produce a malformed message when the
8249    /// system path lacks a trailing slash. This is a regression guard
8250    /// for the "{}\n{}" format string contract.
8251    #[test]
8252    fn ort_mismatch_message_handles_macos_dylib_path() {
8253        let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
8254        assert!(msg.contains("v1.9.0"));
8255        assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
8256        // The dylib path must appear in the auto-fix paragraph (single
8257        // quotes around it) AND in the manual-rm paragraph; verify
8258        // both placements survived the format string.
8259        assert!(
8260            msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
8261            "system path should be quoted in the auto-fix sentence: {msg}"
8262        );
8263    }
8264
8265    // ── managed ONNX Runtime resolver tests ──────────────────────────────────
8266
8267    /// Build a fake `<storage>/onnxruntime/<version>/<libname>` tree. Returns
8268    /// the storage root. `lib_name` is the platform library filename the
8269    /// resolver looks for.
8270    fn fake_managed_ort_tree(storage: &std::path::Path, lib_name: &str, versions: &[(&str, bool)]) {
8271        for (version, has_lib) in versions {
8272            let dir = storage.join("onnxruntime").join(version);
8273            std::fs::create_dir_all(&dir).unwrap();
8274            if *has_lib {
8275                std::fs::write(dir.join(lib_name), b"fake-ort").unwrap();
8276            }
8277        }
8278    }
8279
8280    #[test]
8281    fn managed_ort_resolver_picks_highest_compatible_version() {
8282        let _env_lock = crate::test_env::process_env_lock();
8283        let storage = tempfile::tempdir().unwrap();
8284        fake_managed_ort_tree(
8285            storage.path(),
8286            MANAGED_ORT_LIB_NAME,
8287            &[
8288                ("1.19.0", true), // below the 1.20 floor — must be ignored
8289                ("1.20.1", true),
8290                ("1.24.4", true), // highest compatible — must win
8291                ("1.23.0", true),
8292            ],
8293        );
8294        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
8295        assert_eq!(
8296            found,
8297            storage
8298                .path()
8299                .join("onnxruntime")
8300                .join("1.24.4")
8301                .join(MANAGED_ORT_LIB_NAME)
8302        );
8303    }
8304
8305    #[test]
8306    fn managed_ort_resolver_ignores_non_version_and_pre_120_dirs() {
8307        let _env_lock = crate::test_env::process_env_lock();
8308        let storage = tempfile::tempdir().unwrap();
8309        fake_managed_ort_tree(
8310            storage.path(),
8311            MANAGED_ORT_LIB_NAME,
8312            &[
8313                ("1.19.0", true),     // pre-1.20 — ignored
8314                ("1.24.4.tmp", true), // not a parseable version — ignored
8315                ("latest", true),     // not a version — ignored
8316                ("1.24.4", false),    // compatible but no library file — ignored
8317            ],
8318        );
8319        assert_eq!(
8320            find_managed_onnx_runtime(storage.path()),
8321            None,
8322            "no compatible version with a library file should resolve"
8323        );
8324    }
8325
8326    #[test]
8327    fn managed_ort_resolver_absent_tree_falls_through() {
8328        let _env_lock = crate::test_env::process_env_lock();
8329        let storage = tempfile::tempdir().unwrap();
8330        // No onnxruntime/ dir at all.
8331        assert_eq!(find_managed_onnx_runtime(storage.path()), None);
8332        // Empty onnxruntime/ dir.
8333        std::fs::create_dir_all(storage.path().join("onnxruntime")).unwrap();
8334        assert_eq!(find_managed_onnx_runtime(storage.path()), None);
8335    }
8336
8337    #[test]
8338    fn managed_ort_resolver_prefers_version_root_over_lib_subdir() {
8339        let _env_lock = crate::test_env::process_env_lock();
8340        let storage = tempfile::tempdir().unwrap();
8341        let version_dir = storage.path().join("onnxruntime").join("1.24.4");
8342        std::fs::create_dir_all(version_dir.join("lib")).unwrap();
8343        // Both the version root and the lib/ subdir hold the library; the root
8344        // must win (mirrors resolveCachedOnnxRuntimeDir).
8345        std::fs::write(version_dir.join(MANAGED_ORT_LIB_NAME), b"root").unwrap();
8346        std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
8347        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
8348        assert_eq!(found, version_dir.join(MANAGED_ORT_LIB_NAME));
8349    }
8350
8351    #[test]
8352    fn managed_ort_resolver_accepts_lib_subdir_only() {
8353        let _env_lock = crate::test_env::process_env_lock();
8354        let storage = tempfile::tempdir().unwrap();
8355        let version_dir = storage.path().join("onnxruntime").join("1.24.4");
8356        std::fs::create_dir_all(version_dir.join("lib")).unwrap();
8357        // Library only under lib/ (manual Microsoft-archive install, #71).
8358        std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
8359        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
8360        assert_eq!(found, version_dir.join("lib").join(MANAGED_ORT_LIB_NAME));
8361    }
8362
8363    #[test]
8364    fn managed_ort_resolver_pre_set_env_short_circuits_without_reading_tree() {
8365        let _env_lock = crate::test_env::process_env_lock();
8366        let storage = tempfile::tempdir().unwrap();
8367        // Plant a poison dir that would panic the resolver if it were read:
8368        // a version dir whose name is a valid version but whose library file is
8369        // a directory (so `is_file()` would be false) — harmless, but the point
8370        // is the resolver must never even look.
8371        let poison = storage.path().join("onnxruntime").join("1.24.4");
8372        std::fs::create_dir_all(poison.join(MANAGED_ORT_LIB_NAME)).unwrap();
8373
8374        let before = MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed);
8375        // Pre-set ORT_DYLIB_PATH — the resolver must not run at all.
8376        std::env::set_var("ORT_DYLIB_PATH", "/explicit/override/libonnxruntime.so");
8377        resolve_managed_onnx_runtime(storage.path());
8378        std::env::remove_var("ORT_DYLIB_PATH");
8379        assert_eq!(
8380            MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed),
8381            before,
8382            "resolver must not read the storage tree when ORT_DYLIB_PATH is pre-set"
8383        );
8384    }
8385
8386    #[test]
8387    fn cancelled_build_stops_before_the_next_embed_batch() {
8388        let project = tempfile::tempdir().expect("project directory");
8389        let files = (0..16)
8390            .map(|index| {
8391                let path = project.path().join(format!("batch_{index}.rs"));
8392                std::fs::write(&path, format!("pub fn batch_symbol_{index}() {{}}\n"))
8393                    .expect("write source");
8394                path
8395            })
8396            .collect::<Vec<_>>();
8397        let cancelled = std::sync::atomic::AtomicBool::new(false);
8398        let embed_calls = AtomicUsize::new(0);
8399        let total_chunks = AtomicUsize::new(0);
8400        let mut embed = |texts: Vec<String>| {
8401            let call = embed_calls.fetch_add(1, Ordering::SeqCst) + 1;
8402            assert_eq!(texts.len(), 1, "one chunk per mocked batch");
8403            if call == 1 {
8404                cancelled.store(true, Ordering::SeqCst);
8405            }
8406            Ok(vec![vec![1.0, 2.0, 3.0]])
8407        };
8408        let mut progress = |done: usize, total: usize| {
8409            assert!(done <= total);
8410            total_chunks.store(total, Ordering::SeqCst);
8411        };
8412        let mut should_continue = || !cancelled.load(Ordering::SeqCst);
8413
8414        let error = SemanticIndex::build_with_progress_and_cancellation(
8415            project.path(),
8416            &files,
8417            &mut embed,
8418            1,
8419            &mut progress,
8420            &mut should_continue,
8421        )
8422        .expect_err("the second batch boundary observes cancellation");
8423
8424        let total_chunks = total_chunks.load(Ordering::SeqCst);
8425        assert!(error.contains("semantic build superseded"));
8426        assert_eq!(embed_calls.load(Ordering::SeqCst), 1);
8427        assert!(
8428            total_chunks > 4,
8429            "fixture must contain enough chunks to demonstrate an early stop, got {total_chunks}"
8430        );
8431    }
8432
8433    #[test]
8434    fn managed_ort_resolver_sets_env_when_found() {
8435        let _env_lock = crate::test_env::process_env_lock();
8436        let storage = tempfile::tempdir().unwrap();
8437        fake_managed_ort_tree(storage.path(), MANAGED_ORT_LIB_NAME, &[("1.24.4", true)]);
8438        std::env::remove_var("ORT_DYLIB_PATH");
8439        resolve_managed_onnx_runtime(storage.path());
8440        let set = std::env::var_os("ORT_DYLIB_PATH").expect("resolver sets ORT_DYLIB_PATH");
8441        assert_eq!(
8442            PathBuf::from(set),
8443            storage
8444                .path()
8445                .join("onnxruntime")
8446                .join("1.24.4")
8447                .join(MANAGED_ORT_LIB_NAME)
8448        );
8449        std::env::remove_var("ORT_DYLIB_PATH");
8450    }
8451}