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