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