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