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