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