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}
1634
1635#[derive(Debug, Clone, Copy)]
1636struct IndexedFileMetadata {
1637    mtime: SystemTime,
1638    size: u64,
1639    content_hash: blake3::Hash,
1640}
1641
1642#[derive(Debug, Default, Clone, Copy)]
1643struct SemanticCollectPhaseTimings {
1644    sched: Duration,
1645    read_hash: Duration,
1646    parse: Duration,
1647    extract: Duration,
1648    build: Duration,
1649}
1650
1651impl SemanticCollectPhaseTimings {
1652    fn add_assign(&mut self, other: Self) {
1653        self.sched += other.sched;
1654        self.read_hash += other.read_hash;
1655        self.parse += other.parse;
1656        self.extract += other.extract;
1657        self.build += other.build;
1658    }
1659}
1660
1661type CollectedSemanticFile = (
1662    PathBuf,
1663    Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
1664    SemanticCollectPhaseTimings,
1665);
1666
1667/// Result of an incremental refresh of the semantic index. Counts are file
1668/// counts; `total_processed` is the number of current/deleted files considered.
1669#[derive(Debug, Default, Clone, Copy)]
1670pub struct RefreshSummary {
1671    pub changed: usize,
1672    pub added: usize,
1673    pub deleted: usize,
1674    pub total_processed: usize,
1675}
1676
1677impl RefreshSummary {
1678    /// True when no files were touched.
1679    pub fn is_noop(&self) -> bool {
1680        self.changed == 0 && self.added == 0 && self.deleted == 0
1681    }
1682}
1683
1684#[derive(Debug, Default)]
1685pub struct InvalidatedFilesRefresh {
1686    /// Full replacement entries for `completed_paths`, not just newly embedded
1687    /// chunks. `apply_refresh_update` removes completed paths before extending
1688    /// this set, so reused chunks must travel in this delta too.
1689    pub added_entries: Vec<EmbeddingEntry>,
1690    pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
1691    pub completed_paths: Vec<PathBuf>,
1692    pub summary: RefreshSummary,
1693}
1694
1695#[derive(Debug, Clone)]
1696struct ReusableEmbedding {
1697    embed_text: String,
1698    vector: Vec<f32>,
1699}
1700
1701type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
1702
1703/// Search result from a semantic query
1704#[derive(Debug, Clone)]
1705pub struct SemanticResult {
1706    pub file: PathBuf,
1707    pub name: String,
1708    pub qualified_name: Option<String>,
1709    pub kind: SymbolKind,
1710    pub start_line: u32,
1711    pub end_line: u32,
1712    pub exported: bool,
1713    pub snippet: String,
1714    pub score: f32,
1715    pub rank_score: f32,
1716    pub cap_protected: bool,
1717    pub source: &'static str,
1718}
1719
1720fn relativize_semantic_map<T>(
1721    project_root: &Path,
1722    map: HashMap<PathBuf, T>,
1723) -> Option<HashMap<PathBuf, T>> {
1724    map.into_iter()
1725        .map(|(path, value)| cache_relative_path(project_root, &path).map(|path| (path, value)))
1726        .collect()
1727}
1728
1729impl SemanticIndex {
1730    fn from_shared_base(project_root: PathBuf, shared_base: Arc<SharedSemanticBase>) -> Self {
1731        debug_assert!(project_root.is_absolute());
1732        Self {
1733            entries: Vec::new(),
1734            file_mtimes: HashMap::new(),
1735            file_sizes: HashMap::new(),
1736            any_missing_sizes: false,
1737            file_hashes: HashMap::new(),
1738            dimension: shared_base.dimension,
1739            fingerprint: shared_base.fingerprint.clone(),
1740            project_root,
1741            deferred_files: HashSet::new(),
1742            shared_base: Some(shared_base),
1743        }
1744    }
1745
1746    fn into_shared_base(mut self) -> Option<SharedSemanticBase> {
1747        for entry in &mut self.entries {
1748            entry.chunk.file = cache_relative_path(&self.project_root, &entry.chunk.file)?;
1749        }
1750        let deferred_files = self
1751            .deferred_files
1752            .into_iter()
1753            .map(|path| cache_relative_path(&self.project_root, &path))
1754            .collect::<Option<HashSet<_>>>()?;
1755        Some(SharedSemanticBase {
1756            entries: self.entries,
1757            file_mtimes: relativize_semantic_map(&self.project_root, self.file_mtimes)?,
1758            file_sizes: relativize_semantic_map(&self.project_root, self.file_sizes)?,
1759            any_missing_sizes: self.any_missing_sizes,
1760            file_hashes: relativize_semantic_map(&self.project_root, self.file_hashes)?,
1761            dimension: self.dimension,
1762            fingerprint: self.fingerprint,
1763            deferred_files,
1764        })
1765    }
1766
1767    fn materialize_shared_base(&mut self) {
1768        let Some(base) = self.shared_base.take() else {
1769            return;
1770        };
1771        self.entries = base
1772            .entries
1773            .iter()
1774            .cloned()
1775            .map(|mut entry| {
1776                entry.chunk.file = self.project_root.join(&entry.chunk.file);
1777                entry
1778            })
1779            .collect();
1780        self.file_mtimes = base
1781            .file_mtimes
1782            .iter()
1783            .map(|(path, value)| (self.project_root.join(path), *value))
1784            .collect();
1785        self.file_sizes = base
1786            .file_sizes
1787            .iter()
1788            .map(|(path, value)| (self.project_root.join(path), *value))
1789            .collect();
1790        self.any_missing_sizes = base.any_missing_sizes;
1791        self.file_hashes = base
1792            .file_hashes
1793            .iter()
1794            .map(|(path, value)| (self.project_root.join(path), *value))
1795            .collect();
1796        self.dimension = base.dimension;
1797        self.fingerprint = base.fingerprint.clone();
1798        self.deferred_files = base
1799            .deferred_files
1800            .iter()
1801            .map(|path| self.project_root.join(path))
1802            .collect();
1803    }
1804
1805    pub fn new(project_root: PathBuf, dimension: usize) -> Self {
1806        debug_assert!(project_root.is_absolute());
1807        Self {
1808            entries: Vec::new(),
1809            file_mtimes: HashMap::new(),
1810            file_sizes: HashMap::new(),
1811            any_missing_sizes: false,
1812            file_hashes: HashMap::new(),
1813            dimension,
1814            fingerprint: None,
1815            project_root,
1816            deferred_files: HashSet::new(),
1817            shared_base: None,
1818        }
1819    }
1820
1821    /// Number of embedded symbol entries.
1822    pub fn entry_count(&self) -> usize {
1823        self.shared_base
1824            .as_ref()
1825            .map(|base| base.entries.len())
1826            .unwrap_or_else(|| self.entries.len())
1827    }
1828
1829    /// Estimate resident semantic-index bytes from the vectors and metadata
1830    /// actually held by each entry. This intentionally excludes allocator and
1831    /// hash-table bucket overhead, which are not cheaply observable.
1832    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
1833        if let Some(base) = &self.shared_base {
1834            return crate::memory::MemoryEstimate::estimated(0)
1835                .count("entries", base.entries.len())
1836                .count("dimensions", base.dimension)
1837                .count("indexed_files", base.file_mtimes.len())
1838                .count("shared_base_entries", base.entries.len())
1839                .count("overlay_entries", 0)
1840                .count_u64("vector_bytes", 0)
1841                .count_u64("text_bytes", 0)
1842                .count_u64("metadata_bytes", 0);
1843        }
1844        if self.entries.is_empty()
1845            && self.file_mtimes.is_empty()
1846            && self.file_sizes.is_empty()
1847            && self.file_hashes.is_empty()
1848            && self.deferred_files.is_empty()
1849        {
1850            return crate::memory::MemoryEstimate::estimated(0)
1851                .count("entries", 0)
1852                .count("dimensions", self.dimension)
1853                .count("indexed_files", 0)
1854                .count_u64("vector_bytes", 0)
1855                .count_u64("text_bytes", 0)
1856                .count_u64("metadata_bytes", 0)
1857                .count_u64("average_text_bytes", 0)
1858                .count_u64("average_metadata_bytes", 0);
1859        }
1860        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1861            bytes.saturating_add(
1862                crate::memory::usize_to_u64(entry.vector.len())
1863                    .saturating_mul(std::mem::size_of::<f32>() as u64),
1864            )
1865        });
1866        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
1867            let chunk = &entry.chunk;
1868            bytes
1869                .saturating_add(crate::memory::path_bytes(&chunk.file))
1870                .saturating_add(crate::memory::usize_to_u64(chunk.name.len()))
1871                .saturating_add(
1872                    chunk
1873                        .qualified_name
1874                        .as_ref()
1875                        .map(|name| crate::memory::usize_to_u64(name.len()))
1876                        .unwrap_or(0),
1877                )
1878                .saturating_add(crate::memory::usize_to_u64(chunk.embed_text.len()))
1879                .saturating_add(crate::memory::usize_to_u64(chunk.snippet.len()))
1880        });
1881        let entry_metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
1882            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64);
1883        let file_metadata_bytes = self
1884            .file_mtimes
1885            .keys()
1886            .chain(self.file_sizes.keys())
1887            .chain(self.file_hashes.keys())
1888            .chain(self.deferred_files.iter())
1889            .map(|path| crate::memory::path_bytes(path))
1890            .fold(0u64, u64::saturating_add)
1891            .saturating_add(
1892                crate::memory::usize_to_u64(self.file_mtimes.len())
1893                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
1894            )
1895            .saturating_add(
1896                crate::memory::usize_to_u64(self.file_sizes.len())
1897                    .saturating_mul(std::mem::size_of::<u64>() as u64),
1898            )
1899            .saturating_add(
1900                crate::memory::usize_to_u64(self.file_hashes.len())
1901                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
1902            );
1903        let index_metadata_bytes = crate::memory::path_bytes(&self.project_root).saturating_add(
1904            self.fingerprint
1905                .as_ref()
1906                .map(|fingerprint| {
1907                    crate::memory::usize_to_u64(fingerprint.backend.len())
1908                        .saturating_add(crate::memory::usize_to_u64(fingerprint.model.len()))
1909                        .saturating_add(crate::memory::usize_to_u64(fingerprint.base_url.len()))
1910                })
1911                .unwrap_or(0),
1912        );
1913        let metadata_bytes = entry_metadata_bytes
1914            .saturating_add(file_metadata_bytes)
1915            .saturating_add(index_metadata_bytes);
1916        let entry_count = crate::memory::usize_to_u64(self.entries.len());
1917        crate::memory::MemoryEstimate::estimated(
1918            vector_bytes
1919                .saturating_add(text_bytes)
1920                .saturating_add(metadata_bytes),
1921        )
1922        .count("entries", self.entries.len())
1923        .count("dimensions", self.dimension)
1924        .count("indexed_files", self.file_mtimes.len())
1925        .count_u64("vector_bytes", vector_bytes)
1926        .count_u64("text_bytes", text_bytes)
1927        .count_u64("metadata_bytes", metadata_bytes)
1928        .count_u64(
1929            "average_text_bytes",
1930            text_bytes.checked_div(entry_count).unwrap_or(0),
1931        )
1932        .count_u64(
1933            "average_metadata_bytes",
1934            metadata_bytes.checked_div(entry_count).unwrap_or(0),
1935        )
1936    }
1937
1938    /// Number of files currently tracked by the semantic index.
1939    pub fn indexed_file_count(&self) -> usize {
1940        self.shared_base
1941            .as_ref()
1942            .map(|base| base.file_mtimes.len())
1943            .unwrap_or_else(|| self.file_mtimes.len())
1944    }
1945
1946    /// Human-readable status label for the index.
1947    pub fn status_label(&self) -> &'static str {
1948        if self.entry_count() == 0 {
1949            "empty"
1950        } else {
1951            "ready"
1952        }
1953    }
1954
1955    fn collect_chunks(
1956        project_root: &Path,
1957        files: &[PathBuf],
1958    ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
1959        let collect_started = Instant::now();
1960        let collect_one = |file: &Path, sched: Duration| {
1961            let mut phases = SemanticCollectPhaseTimings {
1962                sched,
1963                ..SemanticCollectPhaseTimings::default()
1964            };
1965            let result = collect_semantic_file(project_root, file, &mut phases);
1966            (file.to_path_buf(), result, phases)
1967        };
1968        let per_file: Vec<CollectedSemanticFile> = if files.len() <= 2 {
1969            files
1970                .iter()
1971                .map(|file| collect_one(file, Duration::ZERO))
1972                .collect()
1973        } else {
1974            files
1975                .par_iter()
1976                .map(|file| collect_one(file, collect_started.elapsed()))
1977                .collect()
1978        };
1979
1980        let mut chunks: Vec<SemanticChunk> = Vec::new();
1981        let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
1982        let mut phases = SemanticCollectPhaseTimings::default();
1983
1984        for (file, result, file_phases) in per_file {
1985            phases.add_assign(file_phases);
1986            match result {
1987                Ok((metadata, file_chunks)) => {
1988                    file_metadata.insert(file, metadata);
1989                    chunks.extend(file_chunks);
1990                }
1991                Err(error) => {
1992                    // "unsupported file extension" is expected for non-code files
1993                    // (json, xml, .gitignore, etc.) that get included in the
1994                    // project walk. Pre-fix this was swallowed by .unwrap_or_default();
1995                    // we now skip silently to keep the log clean. Only real read/parse
1996                    // errors are worth surfacing.
1997                    if error == "unsupported file extension" {
1998                        continue;
1999                    }
2000                    slog_warn!(
2001                        "failed to collect semantic chunks for {}: {}",
2002                        file.display(),
2003                        error
2004                    );
2005                }
2006            }
2007        }
2008
2009        let collect_ms = collect_started
2010            .elapsed()
2011            .as_millis()
2012            .min(u128::from(u64::MAX)) as u64;
2013        crate::logging::note_semantic_collect(chunks.len(), file_metadata.len(), collect_ms);
2014        slog_info!(
2015            "semantic collect: {} chunks from {} files in {} ms",
2016            chunks.len(),
2017            file_metadata.len(),
2018            collect_ms
2019        );
2020        if collect_ms > 50 {
2021            slog_info!(
2022                "semantic collect phases: sched={}ms read_hash={}ms parse={}ms extract={}ms build={}ms",
2023                phases.sched.as_millis(),
2024                phases.read_hash.as_millis(),
2025                phases.parse.as_millis(),
2026                phases.extract.as_millis(),
2027                phases.build.as_millis(),
2028            );
2029        }
2030
2031        (chunks, file_metadata)
2032    }
2033
2034    fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
2035        let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2036        let mut reuse_map: ChunkReuseMap = HashMap::new();
2037
2038        for entry in &self.entries {
2039            if !requested.contains(entry.chunk.file.as_path()) {
2040                continue;
2041            }
2042
2043            // `embed_text` is already persisted in the current on-disk format,
2044            // so refresh-time reuse can hash it in memory and confirm the exact
2045            // string without bumping `SEMANTIC_INDEX_VERSION` and forcing every
2046            // user through a full rebuild.
2047            let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
2048            reuse_map
2049                .entry(entry.chunk.file.clone())
2050                .or_default()
2051                .entry(hash)
2052                .or_default()
2053                .push(ReusableEmbedding {
2054                    embed_text: entry.chunk.embed_text.clone(),
2055                    vector: entry.vector.clone(),
2056                });
2057        }
2058
2059        reuse_map
2060    }
2061
2062    fn reusable_vector_for_chunk(
2063        reuse_map: &ChunkReuseMap,
2064        chunk: &SemanticChunk,
2065    ) -> Option<Vec<f32>> {
2066        let hash = blake3::hash(chunk.embed_text.as_bytes());
2067        reuse_map
2068            .get(&chunk.file)?
2069            .get(&hash)?
2070            .iter()
2071            .find(|candidate| candidate.embed_text == chunk.embed_text)
2072            .map(|candidate| candidate.vector.clone())
2073    }
2074
2075    fn entries_for_chunks_with_reuse<F, P>(
2076        chunks: Vec<SemanticChunk>,
2077        reuse_map: &ChunkReuseMap,
2078        embed_fn: &mut F,
2079        max_batch_size: usize,
2080        initial_observed_dimension: Option<usize>,
2081        refresh_label: &str,
2082        progress: &mut P,
2083    ) -> Result<(Vec<EmbeddingEntry>, Option<usize>), String>
2084    where
2085        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2086        P: FnMut(usize, usize),
2087    {
2088        let total_chunks = chunks.len();
2089        progress(0, total_chunks);
2090
2091        let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
2092        let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
2093
2094        for (chunk_index, chunk) in chunks.into_iter().enumerate() {
2095            if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
2096                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2097            } else {
2098                misses.push((chunk_index, chunk));
2099            }
2100        }
2101
2102        let mut completed = total_chunks.saturating_sub(misses.len());
2103        if completed > 0 {
2104            progress(completed, total_chunks);
2105        }
2106
2107        let batch_size = max_batch_size.max(1);
2108        let mut observed_dimension = initial_observed_dimension;
2109
2110        for batch_start in (0..misses.len()).step_by(batch_size) {
2111            let batch_end = (batch_start + batch_size).min(misses.len());
2112            let batch_texts: Vec<String> = misses[batch_start..batch_end]
2113                .iter()
2114                .map(|(_, chunk)| chunk.embed_text.clone())
2115                .collect();
2116
2117            let vectors = embed_fn(batch_texts)?;
2118            validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2119
2120            if let Some(dim) = vectors.first().map(|vector| vector.len()) {
2121                match observed_dimension {
2122                    None => observed_dimension = Some(dim),
2123                    Some(expected) if dim != expected => {
2124                        return Err(format!(
2125                            "embedding dimension changed during {refresh_label}: \
2126                             cached index uses {expected}, new vectors use {dim}"
2127                        ));
2128                    }
2129                    _ => {}
2130                }
2131            }
2132
2133            for (i, vector) in vectors.into_iter().enumerate() {
2134                let (chunk_index, chunk) = misses[batch_start + i].clone();
2135                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
2136            }
2137
2138            completed += batch_end - batch_start;
2139            progress(completed, total_chunks);
2140        }
2141
2142        let entries = entries_by_chunk
2143            .into_iter()
2144            .map(|entry| entry.expect("semantic refresh accounted for every chunk"))
2145            .collect();
2146
2147        Ok((entries, observed_dimension))
2148    }
2149
2150    fn build_from_chunks<F, P>(
2151        project_root: &Path,
2152        chunks: Vec<SemanticChunk>,
2153        file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
2154        embed_fn: &mut F,
2155        max_batch_size: usize,
2156        mut progress: Option<&mut P>,
2157    ) -> Result<Self, String>
2158    where
2159        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2160        P: FnMut(usize, usize),
2161    {
2162        debug_assert!(project_root.is_absolute());
2163        let total_chunks = chunks.len();
2164
2165        if chunks.is_empty() {
2166            return Ok(Self {
2167                entries: Vec::new(),
2168                file_mtimes: file_metadata
2169                    .iter()
2170                    .map(|(path, metadata)| (path.clone(), metadata.mtime))
2171                    .collect(),
2172                file_sizes: file_metadata
2173                    .iter()
2174                    .map(|(path, metadata)| (path.clone(), metadata.size))
2175                    .collect(),
2176                any_missing_sizes: false,
2177                file_hashes: file_metadata
2178                    .into_iter()
2179                    .map(|(path, metadata)| (path, metadata.content_hash))
2180                    .collect(),
2181                dimension: DEFAULT_DIMENSION,
2182                fingerprint: None,
2183                project_root: project_root.to_path_buf(),
2184                deferred_files: HashSet::new(),
2185                shared_base: None,
2186            });
2187        }
2188
2189        // Embed in batches
2190        let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
2191        let mut expected_dimension: Option<usize> = None;
2192        let batch_size = max_batch_size.max(1);
2193        let embed_started = std::time::Instant::now();
2194        let batch_count = total_chunks.div_ceil(batch_size);
2195        for batch_start in (0..chunks.len()).step_by(batch_size) {
2196            let batch_end = (batch_start + batch_size).min(chunks.len());
2197            let batch_texts: Vec<String> = chunks[batch_start..batch_end]
2198                .iter()
2199                .map(|c| c.embed_text.clone())
2200                .collect();
2201
2202            let vectors = embed_fn(batch_texts)?;
2203            validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
2204
2205            // Track consistent dimension across all batches
2206            if let Some(dim) = vectors.first().map(|v| v.len()) {
2207                match expected_dimension {
2208                    None => expected_dimension = Some(dim),
2209                    Some(expected) if dim != expected => {
2210                        return Err(format!(
2211                            "embedding dimension changed across batches: expected {expected}, got {dim}"
2212                        ));
2213                    }
2214                    _ => {}
2215                }
2216            }
2217
2218            for (i, vector) in vectors.into_iter().enumerate() {
2219                let chunk_idx = batch_start + i;
2220                entries.push(EmbeddingEntry::new(chunks[chunk_idx].clone(), vector));
2221            }
2222
2223            if let Some(callback) = progress.as_mut() {
2224                callback(entries.len(), total_chunks);
2225            }
2226        }
2227
2228        let embed_ms = embed_started.elapsed().as_millis();
2229        let rate = (total_chunks as u128 * 1000)
2230            .checked_div(embed_ms)
2231            .unwrap_or(0) as u64;
2232        slog_info!(
2233            "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s)",
2234            total_chunks,
2235            batch_count,
2236            embed_ms,
2237            rate
2238        );
2239
2240        let dimension = entries
2241            .first()
2242            .map(|e| e.vector.len())
2243            .unwrap_or(DEFAULT_DIMENSION);
2244
2245        Ok(Self {
2246            entries,
2247            file_mtimes: file_metadata
2248                .iter()
2249                .map(|(path, metadata)| (path.clone(), metadata.mtime))
2250                .collect(),
2251            file_sizes: file_metadata
2252                .iter()
2253                .map(|(path, metadata)| (path.clone(), metadata.size))
2254                .collect(),
2255            any_missing_sizes: false,
2256            file_hashes: file_metadata
2257                .into_iter()
2258                .map(|(path, metadata)| (path, metadata.content_hash))
2259                .collect(),
2260            dimension,
2261            fingerprint: None,
2262            project_root: project_root.to_path_buf(),
2263            deferred_files: HashSet::new(),
2264            shared_base: None,
2265        })
2266    }
2267
2268    /// Build the semantic index from a set of files using the provided embedding function.
2269    /// `embed_fn` takes a batch of texts and returns a batch of embedding vectors.
2270    pub fn build<F>(
2271        project_root: &Path,
2272        files: &[PathBuf],
2273        embed_fn: &mut F,
2274        max_batch_size: usize,
2275    ) -> Result<Self, String>
2276    where
2277        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2278    {
2279        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2280        Self::build_from_chunks(
2281            project_root,
2282            chunks,
2283            file_mtimes,
2284            embed_fn,
2285            max_batch_size,
2286            Option::<&mut fn(usize, usize)>::None,
2287        )
2288    }
2289
2290    /// Build the semantic index and report embedding progress using entry counts.
2291    pub fn build_with_progress<F, P>(
2292        project_root: &Path,
2293        files: &[PathBuf],
2294        embed_fn: &mut F,
2295        max_batch_size: usize,
2296        progress: &mut P,
2297    ) -> Result<Self, String>
2298    where
2299        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2300        P: FnMut(usize, usize),
2301    {
2302        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files);
2303        let total_chunks = chunks.len();
2304        progress(0, total_chunks);
2305        Self::build_from_chunks(
2306            project_root,
2307            chunks,
2308            file_mtimes,
2309            embed_fn,
2310            max_batch_size,
2311            Some(progress),
2312        )
2313    }
2314
2315    /// Incrementally refresh entries for changed/new files only, preserving cached
2316    /// embeddings for unchanged files. Used when loading the index from disk and
2317    /// finding that a small fraction of files have moved on, deleted, or appeared.
2318    ///
2319    /// Returns `RefreshSummary` describing what changed. On success, `self` is
2320    /// mutated in place and remains a valid index.
2321    ///
2322    /// `current_files` is the full set of files the project considers indexable
2323    /// (typically `walk_project_files(...)`). Files in the cache that are no
2324    /// longer in this set are treated as deleted.
2325    pub fn refresh_stale_files<F, P>(
2326        &mut self,
2327        project_root: &Path,
2328        current_files: &[PathBuf],
2329        embed_fn: &mut F,
2330        max_batch_size: usize,
2331        progress: &mut P,
2332    ) -> Result<RefreshSummary, String>
2333    where
2334        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2335        P: FnMut(usize, usize),
2336    {
2337        self.refresh_stale_files_with_strategy(
2338            project_root,
2339            current_files,
2340            embed_fn,
2341            max_batch_size,
2342            progress,
2343            cache_freshness::VerifyStrategy::Strict,
2344        )
2345    }
2346
2347    pub(crate) fn refresh_stale_files_with_strategy<F, P>(
2348        &mut self,
2349        project_root: &Path,
2350        current_files: &[PathBuf],
2351        embed_fn: &mut F,
2352        max_batch_size: usize,
2353        progress: &mut P,
2354        verify_strategy: cache_freshness::VerifyStrategy,
2355    ) -> Result<RefreshSummary, String>
2356    where
2357        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2358        P: FnMut(usize, usize),
2359    {
2360        self.materialize_shared_base();
2361        self.backfill_missing_file_sizes();
2362
2363        // 1. Bucket files into deleted / changed / added.
2364        let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
2365        self.deferred_files
2366            .retain(|path| current_set.contains(path.as_path()));
2367        let total_processed = current_set.len() + self.file_mtimes.len()
2368            - self
2369                .file_mtimes
2370                .keys()
2371                .filter(|path| current_set.contains(path.as_path()))
2372                .count();
2373
2374        // Files in cache that disappeared from disk OR are no longer in the
2375        // walked set. Both cases need their entries dropped.
2376        enum IndexedFileCheck {
2377            Deleted(PathBuf),
2378            MissingMetadata(PathBuf),
2379            Verified(PathBuf, FreshnessVerdict),
2380        }
2381
2382        let mut deleted: Vec<PathBuf> = Vec::new();
2383        let mut changed: Vec<PathBuf> = Vec::new();
2384        let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
2385        let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
2386        let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
2387
2388        for indexed_path in indexed_paths {
2389            let check_index = checks.len();
2390            if !current_set.contains(indexed_path.as_path()) {
2391                checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
2392                continue;
2393            }
2394            let cached = match (
2395                self.file_mtimes.get(&indexed_path),
2396                self.file_sizes.get(&indexed_path),
2397                self.file_hashes.get(&indexed_path),
2398            ) {
2399                (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
2400                    mtime: *mtime,
2401                    size: *size,
2402                    content_hash: *hash,
2403                }),
2404                _ => None,
2405            };
2406            if let Some(freshness) = cached {
2407                strict_verify_inputs.push((check_index, indexed_path, freshness));
2408                checks.push(None);
2409            } else {
2410                checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
2411            }
2412        }
2413
2414        let verified = match verify_strategy {
2415            cache_freshness::VerifyStrategy::StatFirst => cache_freshness::verify_files_bounded(
2416                strict_verify_inputs,
2417                cache_freshness::VerifyStrategy::StatFirst,
2418            ),
2419            cache_freshness::VerifyStrategy::Strict => {
2420                cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
2421            }
2422        };
2423        for (check_index, path, verdict) in verified {
2424            checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
2425        }
2426
2427        for check in checks {
2428            match check.expect("freshness check should be populated") {
2429                IndexedFileCheck::Deleted(path) => deleted.push(path),
2430                IndexedFileCheck::MissingMetadata(path) => changed.push(path),
2431                IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
2432                IndexedFileCheck::Verified(
2433                    path,
2434                    FreshnessVerdict::ContentFresh {
2435                        new_mtime,
2436                        new_size,
2437                    },
2438                ) => {
2439                    self.file_mtimes.insert(path.clone(), new_mtime);
2440                    self.file_sizes.insert(path, new_size);
2441                }
2442                IndexedFileCheck::Verified(
2443                    path,
2444                    FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
2445                ) => {
2446                    changed.push(path);
2447                }
2448            }
2449        }
2450
2451        // Files in walk that were never indexed.
2452        let mut added: Vec<PathBuf> = Vec::new();
2453        for path in current_files {
2454            if !self.file_mtimes.contains_key(path) {
2455                added.push(path.clone());
2456            }
2457        }
2458
2459        // Fast path: nothing to do.
2460        if deleted.is_empty() && changed.is_empty() && added.is_empty() {
2461            progress(0, 0);
2462            return Ok(RefreshSummary {
2463                total_processed,
2464                ..RefreshSummary::default()
2465            });
2466        }
2467
2468        // 2. Drop entries for deleted files immediately. Changed files are only
2469        //    replaced after successful re-extraction + embedding so transient
2470        //    read/parse errors keep the stale-but-valid cache entry.
2471        if !deleted.is_empty() {
2472            self.remove_indexed_files(&deleted);
2473        }
2474
2475        // 3. Embed the changed + added set, if any.
2476        let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
2477        to_embed.extend(changed.iter().cloned());
2478        to_embed.extend(added.iter().cloned());
2479
2480        if to_embed.is_empty() {
2481            // Only deletions happened.
2482            progress(0, 0);
2483            return Ok(RefreshSummary {
2484                changed: 0,
2485                added: 0,
2486                deleted: deleted.len(),
2487                total_processed,
2488            });
2489        }
2490
2491        let reuse_map = self.build_chunk_reuse_map(&changed);
2492        let (chunks, fresh_metadata) = Self::collect_chunks(project_root, &to_embed);
2493        let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
2494        let vanished = to_embed
2495            .iter()
2496            .filter(|path| {
2497                changed_set.contains(path.as_path())
2498                    && !fresh_metadata.contains_key(*path)
2499                    && !path.exists()
2500            })
2501            .cloned()
2502            .collect::<Vec<_>>();
2503        if !vanished.is_empty() {
2504            self.remove_indexed_files(&vanished);
2505            deleted.extend(vanished);
2506        }
2507
2508        if chunks.is_empty() {
2509            progress(0, 0);
2510            let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2511            for file in &successful_files {
2512                self.deferred_files.remove(file);
2513            }
2514            if !successful_files.is_empty() {
2515                self.entries
2516                    .retain(|entry| !successful_files.contains(&entry.chunk.file));
2517            }
2518            let changed_count = changed
2519                .iter()
2520                .filter(|path| successful_files.contains(*path))
2521                .count();
2522            let added_count = added
2523                .iter()
2524                .filter(|path| successful_files.contains(*path))
2525                .count();
2526            for (file, metadata) in fresh_metadata {
2527                self.file_mtimes.insert(file.clone(), metadata.mtime);
2528                self.file_sizes.insert(file.clone(), metadata.size);
2529                self.file_hashes.insert(file.clone(), metadata.content_hash);
2530            }
2531            return Ok(RefreshSummary {
2532                changed: changed_count,
2533                added: added_count,
2534                deleted: deleted.len(),
2535                total_processed,
2536            });
2537        }
2538
2539        // 4. Build the full replacement set, reusing cached vectors for chunks
2540        //    whose embed_text is unchanged and embedding only cache misses.
2541        let existing_dimension = if self.entries.is_empty() {
2542            None
2543        } else {
2544            Some(self.dimension)
2545        };
2546        let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
2547            chunks,
2548            &reuse_map,
2549            embed_fn,
2550            max_batch_size,
2551            existing_dimension,
2552            "incremental refresh",
2553            progress,
2554        )?;
2555
2556        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2557        for file in &successful_files {
2558            self.deferred_files.remove(file);
2559        }
2560        if !successful_files.is_empty() {
2561            self.entries
2562                .retain(|entry| !successful_files.contains(&entry.chunk.file));
2563        }
2564
2565        self.entries.extend(new_entries);
2566        for (file, metadata) in fresh_metadata {
2567            self.file_mtimes.insert(file.clone(), metadata.mtime);
2568            self.file_sizes.insert(file.clone(), metadata.size);
2569            self.file_hashes.insert(file, metadata.content_hash);
2570        }
2571        if let Some(dim) = observed_dimension {
2572            self.dimension = dim;
2573        }
2574
2575        Ok(RefreshSummary {
2576            changed: changed
2577                .iter()
2578                .filter(|path| successful_files.contains(*path))
2579                .count(),
2580            added: added
2581                .iter()
2582                .filter(|path| successful_files.contains(*path))
2583                .count(),
2584            deleted: deleted.len(),
2585            total_processed,
2586        })
2587    }
2588
2589    /// Refresh exactly the files invalidated by the live watcher, without
2590    /// treating the provided path list as the whole project. This is the
2591    /// watcher-side counterpart to `refresh_stale_files`: it drops any stale
2592    /// entries for the requested paths from this in-memory index, re-extracts
2593    /// whatever still exists on disk, embeds those chunks, and returns the
2594    /// delta needed for another in-memory index to apply the same update.
2595    pub fn refresh_invalidated_files<F, P>(
2596        &mut self,
2597        project_root: &Path,
2598        paths: &[PathBuf],
2599        embed_fn: &mut F,
2600        max_batch_size: usize,
2601        max_files: usize,
2602        progress: &mut P,
2603    ) -> Result<InvalidatedFilesRefresh, String>
2604    where
2605        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2606        P: FnMut(usize, usize),
2607    {
2608        self.materialize_shared_base();
2609        self.backfill_missing_file_sizes();
2610
2611        self.deferred_files.retain(|path| path.exists());
2612        let mut requested_paths = paths.to_vec();
2613        requested_paths.extend(self.deferred_files.iter().cloned());
2614        requested_paths.sort();
2615        requested_paths.dedup();
2616        let total_processed = requested_paths.len();
2617
2618        if requested_paths.is_empty() {
2619            progress(0, 0);
2620            return Ok(InvalidatedFilesRefresh {
2621                summary: RefreshSummary {
2622                    total_processed,
2623                    ..RefreshSummary::default()
2624                },
2625                ..InvalidatedFilesRefresh::default()
2626            });
2627        }
2628
2629        let previously_indexed: HashSet<PathBuf> = requested_paths
2630            .iter()
2631            .filter(|path| self.file_mtimes.contains_key(*path))
2632            .cloned()
2633            .collect();
2634        let reuse_map = self.build_chunk_reuse_map(&requested_paths);
2635
2636        // The watcher path has already invalidated these files in the request
2637        // thread's live index. Mirror that behavior here before inserting any
2638        // fresh chunks so parse/read failures do not resurrect stale entries.
2639        self.remove_indexed_files(&requested_paths);
2640
2641        let existing_paths = requested_paths
2642            .iter()
2643            .filter(|path| path.exists())
2644            .cloned()
2645            .collect::<Vec<_>>();
2646        let deleted = requested_paths
2647            .iter()
2648            .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
2649            .count();
2650
2651        if existing_paths.is_empty() {
2652            for path in &requested_paths {
2653                if !path.exists() {
2654                    self.deferred_files.remove(path);
2655                }
2656            }
2657            progress(0, 0);
2658            return Ok(InvalidatedFilesRefresh {
2659                completed_paths: requested_paths,
2660                summary: RefreshSummary {
2661                    deleted,
2662                    total_processed,
2663                    ..RefreshSummary::default()
2664                },
2665                ..InvalidatedFilesRefresh::default()
2666            });
2667        }
2668
2669        let (mut chunks, mut fresh_metadata) = Self::collect_chunks(project_root, &existing_paths);
2670
2671        let retained_file_count = self.file_mtimes.len();
2672        let changed_successful_count = existing_paths
2673            .iter()
2674            .filter(|path| {
2675                previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
2676            })
2677            .count();
2678        let available_new_files =
2679            max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
2680        let new_successful_files = existing_paths
2681            .iter()
2682            .filter(|path| {
2683                !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
2684            })
2685            .cloned()
2686            .collect::<Vec<_>>();
2687        if new_successful_files.len() > available_new_files {
2688            let allowed_new_files = new_successful_files
2689                .iter()
2690                .take(available_new_files)
2691                .cloned()
2692                .collect::<HashSet<_>>();
2693            let deferred_new_files = new_successful_files
2694                .into_iter()
2695                .filter(|path| !allowed_new_files.contains(path))
2696                .collect::<HashSet<_>>();
2697
2698            fresh_metadata.retain(|file, _| {
2699                previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
2700            });
2701            chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
2702
2703            if !deferred_new_files.is_empty() {
2704                for path in &deferred_new_files {
2705                    self.deferred_files.insert(path.clone());
2706                }
2707                slog_warn!(
2708                    "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
2709                    deferred_new_files.len(),
2710                    max_files
2711                );
2712            }
2713        }
2714
2715        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
2716        for file in &successful_files {
2717            self.deferred_files.remove(file);
2718        }
2719        let changed = successful_files
2720            .iter()
2721            .filter(|path| previously_indexed.contains(path.as_path()))
2722            .count();
2723        let added = successful_files.len().saturating_sub(changed);
2724        let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
2725
2726        if chunks.is_empty() {
2727            progress(0, 0);
2728            for (file, metadata) in fresh_metadata {
2729                let freshness = FileFreshness {
2730                    mtime: metadata.mtime,
2731                    size: metadata.size,
2732                    content_hash: metadata.content_hash,
2733                };
2734                self.file_mtimes.insert(file.clone(), freshness.mtime);
2735                self.file_sizes.insert(file.clone(), freshness.size);
2736                self.file_hashes
2737                    .insert(file.clone(), freshness.content_hash);
2738                updated_metadata.push((file, freshness));
2739            }
2740
2741            return Ok(InvalidatedFilesRefresh {
2742                updated_metadata,
2743                completed_paths: requested_paths,
2744                summary: RefreshSummary {
2745                    changed,
2746                    added,
2747                    deleted,
2748                    total_processed,
2749                },
2750                ..InvalidatedFilesRefresh::default()
2751            });
2752        }
2753
2754        let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
2755        {
2756            None
2757        } else {
2758            Some(self.dimension)
2759        };
2760        let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
2761            chunks,
2762            &reuse_map,
2763            embed_fn,
2764            max_batch_size,
2765            initial_observed_dimension,
2766            "invalidated-file refresh",
2767            progress,
2768        )?;
2769
2770        let added_entries = new_entries.clone();
2771        self.entries.extend(new_entries);
2772        for (file, metadata) in fresh_metadata {
2773            let freshness = FileFreshness {
2774                mtime: metadata.mtime,
2775                size: metadata.size,
2776                content_hash: metadata.content_hash,
2777            };
2778            self.file_mtimes.insert(file.clone(), freshness.mtime);
2779            self.file_sizes.insert(file.clone(), freshness.size);
2780            self.file_hashes
2781                .insert(file.clone(), freshness.content_hash);
2782            updated_metadata.push((file, freshness));
2783        }
2784        if let Some(dim) = observed_dimension {
2785            self.dimension = dim;
2786        }
2787
2788        Ok(InvalidatedFilesRefresh {
2789            added_entries,
2790            updated_metadata,
2791            completed_paths: requested_paths,
2792            summary: RefreshSummary {
2793                changed,
2794                added,
2795                deleted,
2796                total_processed,
2797            },
2798        })
2799    }
2800
2801    pub fn apply_refresh_update(
2802        &mut self,
2803        added_entries: Vec<EmbeddingEntry>,
2804        updated_metadata: Vec<(PathBuf, FileFreshness)>,
2805        completed_paths: &[PathBuf],
2806    ) {
2807        self.materialize_shared_base();
2808        // `added_entries` is the complete replacement set for completed paths:
2809        // freshly embedded misses plus reused chunks carrying refreshed metadata.
2810        // Removing first is safe only because producers include both kinds.
2811        self.remove_indexed_files(completed_paths);
2812
2813        let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
2814        self.entries.extend(added_entries);
2815        for (file, freshness) in updated_metadata {
2816            self.file_mtimes.insert(file.clone(), freshness.mtime);
2817            self.file_sizes.insert(file.clone(), freshness.size);
2818            self.file_hashes.insert(file, freshness.content_hash);
2819        }
2820        if let Some(dim) = observed_dimension {
2821            self.dimension = dim;
2822        }
2823    }
2824
2825    fn remove_indexed_files(&mut self, files: &[PathBuf]) {
2826        let deleted_set: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
2827        self.entries
2828            .retain(|entry| !deleted_set.contains(entry.chunk.file.as_path()));
2829        for path in files {
2830            self.file_mtimes.remove(path);
2831            self.file_sizes.remove(path);
2832            self.file_hashes.remove(path);
2833        }
2834    }
2835
2836    /// Search the index with a query embedding, returning top-K results sorted by relevance
2837    pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
2838        let (entries, dimension) = self
2839            .shared_base
2840            .as_ref()
2841            .map(|base| (base.entries.as_slice(), base.dimension))
2842            .unwrap_or_else(|| (self.entries.as_slice(), self.dimension));
2843        if entries.is_empty() || query_vector.len() != dimension {
2844            return Vec::new();
2845        }
2846
2847        // Query norms are shared by every entry; entry norms are cached because
2848        // remote embedding backends may return non-normalized vectors.
2849        let query_norm = vector_norm(query_vector);
2850        let mut scored: Vec<(f32, usize)> = entries
2851            .iter()
2852            .enumerate()
2853            .map(|(i, entry)| {
2854                let dot = if query_vector.len() == entry.vector.len() {
2855                    dot_product(query_vector, &entry.vector)
2856                } else {
2857                    0.0
2858                };
2859                let denom = query_norm * entry.norm;
2860                let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
2861                if entry.chunk.exported {
2862                    score *= 1.1;
2863                }
2864                (score, i)
2865            })
2866            .collect();
2867
2868        let keep = top_k.min(scored.len());
2869        if keep == 0 {
2870            return Vec::new();
2871        }
2872
2873        if keep < scored.len() {
2874            scored.select_nth_unstable_by(keep, semantic_score_order);
2875            scored.truncate(keep);
2876        }
2877        scored.sort_by(semantic_score_order);
2878
2879        scored
2880            .into_iter()
2881            // Keep the selected best-first slice mapped without reintroducing the
2882            // old `> 0.0` floor: top_k has already been selected, and zero-score
2883            // tail entries remain observable when requested.
2884            .map(|(score, idx)| {
2885                let entry = &entries[idx];
2886                SemanticResult {
2887                    file: if self.shared_base.is_some() {
2888                        self.project_root.join(&entry.chunk.file)
2889                    } else {
2890                        entry.chunk.file.clone()
2891                    },
2892                    name: entry.chunk.name.clone(),
2893                    qualified_name: entry.chunk.qualified_name.clone(),
2894                    kind: entry.chunk.kind.clone(),
2895                    start_line: entry.chunk.start_line,
2896                    end_line: entry.chunk.end_line,
2897                    exported: entry.chunk.exported,
2898                    snippet: entry.chunk.snippet.clone(),
2899                    score,
2900                    rank_score: score,
2901                    cap_protected: false,
2902                    source: "semantic",
2903                }
2904            })
2905            .collect()
2906    }
2907
2908    /// Number of indexed entries
2909    pub fn len(&self) -> usize {
2910        self.entry_count()
2911    }
2912
2913    /// Check if a file needs re-indexing based on mtime/size
2914    pub fn is_file_stale(&self, file: &Path) -> bool {
2915        let relative;
2916        let (file_mtimes, file_sizes, file_hashes, lookup) = if let Some(base) = &self.shared_base {
2917            relative = file
2918                .strip_prefix(&self.project_root)
2919                .unwrap_or(file)
2920                .to_path_buf();
2921            (
2922                &base.file_mtimes,
2923                &base.file_sizes,
2924                &base.file_hashes,
2925                relative.as_path(),
2926            )
2927        } else {
2928            (&self.file_mtimes, &self.file_sizes, &self.file_hashes, file)
2929        };
2930        let Some(stored_mtime) = file_mtimes.get(lookup) else {
2931            return true;
2932        };
2933        let Some(stored_size) = file_sizes.get(lookup) else {
2934            return true;
2935        };
2936        let Some(stored_hash) = file_hashes.get(lookup) else {
2937            return true;
2938        };
2939        let cached = FileFreshness {
2940            mtime: *stored_mtime,
2941            size: *stored_size,
2942            content_hash: *stored_hash,
2943        };
2944        match cache_freshness::verify_file_strict(file, &cached) {
2945            FreshnessVerdict::HotFresh => false,
2946            FreshnessVerdict::ContentFresh { .. } => false,
2947            FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
2948        }
2949    }
2950
2951    fn backfill_missing_file_sizes(&mut self) {
2952        if !self.any_missing_sizes {
2953            return;
2954        }
2955
2956        for path in self.file_mtimes.keys() {
2957            if self.file_sizes.contains_key(path) {
2958                continue;
2959            }
2960            if let Ok(metadata) = fs::metadata(path) {
2961                self.file_sizes.insert(path.clone(), metadata.len());
2962                if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
2963                    self.file_hashes.insert(path.clone(), hash);
2964                }
2965            }
2966        }
2967        self.any_missing_sizes = self
2968            .file_mtimes
2969            .keys()
2970            .any(|path| !self.file_sizes.contains_key(path));
2971    }
2972
2973    /// Remove entries for a specific file
2974    pub fn remove_file(&mut self, file: &Path) {
2975        self.invalidate_file(file);
2976    }
2977
2978    pub fn invalidate_file(&mut self, file: &Path) {
2979        self.materialize_shared_base();
2980        let canonical_file = canonicalize_existing_or_deleted_path(file);
2981        self.entries
2982            .retain(|e| e.chunk.file != file && e.chunk.file != canonical_file);
2983        self.file_mtimes.remove(file);
2984        self.file_sizes.remove(file);
2985        self.file_hashes.remove(file);
2986        if canonical_file.as_path() != file {
2987            self.file_mtimes.remove(&canonical_file);
2988            self.file_sizes.remove(&canonical_file);
2989            self.file_hashes.remove(&canonical_file);
2990        }
2991    }
2992
2993    /// Get the embedding dimension
2994    pub fn dimension(&self) -> usize {
2995        self.shared_base
2996            .as_ref()
2997            .map(|base| base.dimension)
2998            .unwrap_or(self.dimension)
2999    }
3000
3001    pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
3002        self.shared_base
3003            .as_ref()
3004            .and_then(|base| base.fingerprint.as_ref())
3005            .or(self.fingerprint.as_ref())
3006    }
3007
3008    pub fn backend_label(&self) -> Option<&str> {
3009        self.fingerprint().map(|f| f.backend.as_str())
3010    }
3011
3012    pub fn model_label(&self) -> Option<&str> {
3013        self.fingerprint().map(|f| f.model.as_str())
3014    }
3015
3016    pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
3017        self.materialize_shared_base();
3018        self.fingerprint = Some(fingerprint);
3019    }
3020
3021    /// Write the semantic index to disk using atomic temp+rename pattern.
3022    /// Empty indexes are persisted too so a completed rebuild cannot leave an
3023    /// older non-empty snapshot visible to the next process.
3024    pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) -> bool {
3025        if self.shared_base.is_some() {
3026            let mut private = self.clone();
3027            private.materialize_shared_base();
3028            return private.write_to_disk(storage_dir, project_key);
3029        }
3030        let dir = storage_dir.join("semantic").join(project_key);
3031        let data_path = dir.join("semantic.bin");
3032        let access = crate::root_cache::ArtifactAccess::for_root(&self.project_root);
3033        if !access.allows_write(project_key, &data_path) {
3034            return false;
3035        }
3036        if let Err(e) = fs::create_dir_all(&dir) {
3037            slog_warn!("failed to create semantic cache dir: {}", e);
3038            return false;
3039        }
3040        let tmp_path = dir.join(format!(
3041            "semantic.bin.tmp.{}.{}",
3042            std::process::id(),
3043            SystemTime::now()
3044                .duration_since(SystemTime::UNIX_EPOCH)
3045                .unwrap_or(Duration::ZERO)
3046                .as_nanos()
3047        ));
3048        let write_result = (|| -> io::Result<usize> {
3049            let file = fs::File::create(&tmp_path)?;
3050            let mut writer = BufWriter::new(file);
3051            let bytes_written = self.write_to_writer(&mut writer)?;
3052            writer.flush()?;
3053            writer.get_ref().sync_all()?;
3054            Ok(bytes_written)
3055        })();
3056        let bytes_written = match write_result {
3057            Ok(bytes_written) => bytes_written,
3058            Err(e) => {
3059                slog_warn!("failed to write semantic index: {}", e);
3060                let _ = fs::remove_file(&tmp_path);
3061                return false;
3062            }
3063        };
3064        if let Err(e) = crate::fs_lock::rename_over(&tmp_path, &data_path) {
3065            slog_warn!("failed to rename semantic index: {}", e);
3066            let _ = fs::remove_file(&tmp_path);
3067            return false;
3068        }
3069        slog_info!(
3070            "semantic index persisted: {} entries, {:.1} KB",
3071            self.entries.len(),
3072            bytes_written as f64 / 1024.0
3073        );
3074        true
3075    }
3076
3077    /// Read the semantic index from disk
3078    pub fn read_from_disk(
3079        storage_dir: &Path,
3080        project_key: &str,
3081        current_canonical_root: &Path,
3082        is_worktree_bridge: bool,
3083        expected_fingerprint: Option<&str>,
3084    ) -> Option<Self> {
3085        debug_assert!(current_canonical_root.is_absolute());
3086        let data_path = storage_dir
3087            .join("semantic")
3088            .join(project_key)
3089            .join("semantic.bin");
3090        let file = fs::File::open(&data_path).ok()?;
3091        let file_len = usize::try_from(file.metadata().ok()?.len()).ok()?;
3092        if file_len < HEADER_BYTES_V1 {
3093            slog_warn!(
3094                "corrupt semantic index (too small: {} bytes), removing",
3095                file_len
3096            );
3097            if !is_worktree_bridge {
3098                let _ = fs::remove_file(&data_path);
3099            }
3100            return None;
3101        }
3102
3103        let mut reader = BufReader::new(file);
3104        let mut version_buf = [0u8; 1];
3105        reader.read_exact(&mut version_buf).ok()?;
3106        let version = version_buf[0];
3107        if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
3108            slog_info!(
3109            "cached semantic index version {} is not compatible with {}, rebuilding without deleting the shared artifact",
3110            version,
3111            SEMANTIC_INDEX_VERSION_V7
3112        );
3113            return None;
3114        }
3115        match Self::from_reader_after_version(
3116            reader,
3117            version,
3118            current_canonical_root,
3119            Some(file_len),
3120            1,
3121        ) {
3122            Ok(index) => {
3123                if let Some(expected) = expected_fingerprint {
3124                    let matches = index
3125                        .fingerprint()
3126                        .map(|fingerprint| fingerprint.matches_expected(expected))
3127                        .unwrap_or(false);
3128                    if !matches {
3129                        log_fingerprint_mismatch(index.fingerprint(), expected);
3130                        return None;
3131                    }
3132                }
3133                slog_info!(
3134                    "loaded semantic index from disk: {} entries",
3135                    index.entries.len()
3136                );
3137                Some(index)
3138            }
3139            Err(e) => {
3140                slog_warn!("corrupt semantic index, rebuilding: {}", e);
3141                if !is_worktree_bridge {
3142                    let _ = fs::remove_file(&data_path);
3143                }
3144                None
3145            }
3146        }
3147    }
3148
3149    pub(crate) fn read_from_disk_borrow_tolerant(
3150        storage_dir: &Path,
3151        project_key: &str,
3152        current_canonical_root: &Path,
3153    ) -> Option<Self> {
3154        let data_path = storage_dir
3155            .join("semantic")
3156            .join(project_key)
3157            .join("semantic.bin");
3158        let (fingerprint, artifact_content_hash) = match borrowed_artifact_identity(&data_path) {
3159            Ok(identity) => identity,
3160            Err(error) => {
3161                slog_warn!(
3162                    "semantic shared-base identity unavailable ({}); loading a private borrowed copy",
3163                    error
3164                );
3165                return Self::read_from_disk(
3166                    storage_dir,
3167                    project_key,
3168                    current_canonical_root,
3169                    true,
3170                    None,
3171                );
3172            }
3173        };
3174        let key = SharedSemanticBaseKey {
3175            artifact_cache_key: project_key.to_string(),
3176            fingerprint,
3177            artifact_content_hash,
3178        };
3179
3180        {
3181            let mut registry = shared_semantic_bases()
3182                .lock()
3183                .unwrap_or_else(std::sync::PoisonError::into_inner);
3184            registry.retain(|_, base| base.strong_count() > 0);
3185            if let Some(base) = registry.get(&key).and_then(Weak::upgrade) {
3186                SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3187                return Some(Self::from_shared_base(
3188                    current_canonical_root.to_path_buf(),
3189                    base,
3190                ));
3191            }
3192            if registry.keys().any(|existing| {
3193                existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3194            }) {
3195                slog_warn!(
3196                    "semantic shared-base fingerprint or artifact hash changed for key {}; loading a private borrowed copy",
3197                    project_key
3198                );
3199                return Self::read_from_disk(
3200                    storage_dir,
3201                    project_key,
3202                    current_canonical_root,
3203                    true,
3204                    None,
3205                );
3206            }
3207        }
3208
3209        let private = Self::read_from_disk(
3210            storage_dir,
3211            project_key,
3212            current_canonical_root,
3213            true,
3214            Some(&key.fingerprint),
3215        )?;
3216        let Some(base) = private.clone().into_shared_base() else {
3217            slog_warn!(
3218                "semantic shared-base paths could not be normalized for key {}; loading a private borrowed copy",
3219                project_key
3220            );
3221            return Some(private);
3222        };
3223        let base = Arc::new(base);
3224
3225        let mut registry = shared_semantic_bases()
3226            .lock()
3227            .unwrap_or_else(std::sync::PoisonError::into_inner);
3228        registry.retain(|_, base| base.strong_count() > 0);
3229        if let Some(existing) = registry.get(&key).and_then(Weak::upgrade) {
3230            SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
3231            return Some(Self::from_shared_base(
3232                current_canonical_root.to_path_buf(),
3233                existing,
3234            ));
3235        }
3236        if registry.keys().any(|existing| {
3237            existing.artifact_cache_key == key.artifact_cache_key && existing != &key
3238        }) {
3239            slog_warn!(
3240                "semantic shared-base identity changed while loading key {}; retaining a private borrowed copy",
3241                project_key
3242            );
3243            return Some(private);
3244        }
3245        registry.insert(key, Arc::downgrade(&base));
3246        SHARED_SEMANTIC_BASE_LOADS.fetch_add(1, Ordering::Relaxed);
3247        Some(Self::from_shared_base(
3248            current_canonical_root.to_path_buf(),
3249            base,
3250        ))
3251    }
3252
3253    /// Serialize the index to bytes for disk persistence
3254    pub fn to_bytes(&self) -> Vec<u8> {
3255        if self.shared_base.is_some() {
3256            let mut private = self.clone();
3257            private.materialize_shared_base();
3258            return private.to_bytes();
3259        }
3260        let mut buf = Vec::new();
3261        self.write_to_writer(&mut buf)
3262            .expect("writing semantic index to Vec cannot fail");
3263        buf
3264    }
3265
3266    fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
3267        let mut bytes_written = 0usize;
3268        let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
3269            let encoded = fingerprint.as_string();
3270            if encoded.is_empty() {
3271                None
3272            } else {
3273                Some(encoded)
3274            }
3275        });
3276        let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
3277        let file_mtime_count = self
3278            .file_mtimes
3279            .iter()
3280            .filter(|(path, _)| cache_relative_path(&self.project_root, path).is_some())
3281            .count();
3282        let entry_count = self
3283            .entries
3284            .iter()
3285            .filter(|entry| cache_relative_path(&self.project_root, &entry.chunk.file).is_some())
3286            .count();
3287
3288        // Header: version(1) + dimension(4) + entry_count(4) + fingerprint_len(4) + fingerprint
3289        //
3290        // V7 is the single write format. Layout extends V6 with per-entry
3291        // qualified_name metadata while preserving the embedding fingerprint:
3292        //   - fingerprint is always represented (absent ⇒ fingerprint_len=0,
3293        //     no bytes follow). Uniform format simplifies the reader.
3294        //   - paths are relative to project_root.
3295        //   - file metadata stored as secs(u64) + subsec_nanos(u32) + size(u64) + blake3(32).
3296        //     Preserves full APFS/ext4/NTFS precision and catches mtime ties.
3297        //
3298        // V1/V2 remain readable for backward compatibility (see from_bytes).
3299        // V3/V4 load as compatible formats but are rejected on disk so snippets
3300        // and file sizes are rebuilt once. V6 remains accepted on disk and
3301        // yields qualified_name=None until the next V7 write.
3302        let version = SEMANTIC_INDEX_VERSION_V7;
3303        write_counted(writer, &[version], &mut bytes_written)?;
3304        write_counted(
3305            writer,
3306            &(self.dimension as u32).to_le_bytes(),
3307            &mut bytes_written,
3308        )?;
3309        write_counted(
3310            writer,
3311            &(entry_count as u32).to_le_bytes(),
3312            &mut bytes_written,
3313        )?;
3314        write_counted(
3315            writer,
3316            &(fp_bytes_ref.len() as u32).to_le_bytes(),
3317            &mut bytes_written,
3318        )?;
3319        write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
3320
3321        // File mtime table: count(4) + entries
3322        // V3 layout per entry: path_len(4) + path + secs(8) + subsec_nanos(4)
3323        write_counted(
3324            writer,
3325            &(file_mtime_count as u32).to_le_bytes(),
3326            &mut bytes_written,
3327        )?;
3328        for (path, mtime) in &self.file_mtimes {
3329            let Some(relative) = cache_relative_path(&self.project_root, path) else {
3330                continue;
3331            };
3332            let relative = relative.to_string_lossy();
3333            let path_bytes = relative.as_bytes();
3334            write_counted(
3335                writer,
3336                &(path_bytes.len() as u32).to_le_bytes(),
3337                &mut bytes_written,
3338            )?;
3339            write_counted(writer, path_bytes, &mut bytes_written)?;
3340            let duration = mtime
3341                .duration_since(SystemTime::UNIX_EPOCH)
3342                .unwrap_or_default();
3343            write_counted(
3344                writer,
3345                &duration.as_secs().to_le_bytes(),
3346                &mut bytes_written,
3347            )?;
3348            write_counted(
3349                writer,
3350                &duration.subsec_nanos().to_le_bytes(),
3351                &mut bytes_written,
3352            )?;
3353            let size = self.file_sizes.get(path).copied().unwrap_or_default();
3354            write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
3355            let hash = self
3356                .file_hashes
3357                .get(path)
3358                .copied()
3359                .unwrap_or_else(cache_freshness::zero_hash);
3360            write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
3361        }
3362
3363        // Entries: each is metadata + vector
3364        for entry in &self.entries {
3365            let Some(relative) = cache_relative_path(&self.project_root, &entry.chunk.file) else {
3366                continue;
3367            };
3368            let c = &entry.chunk;
3369
3370            // File path
3371            let relative = relative.to_string_lossy();
3372            let file_bytes = relative.as_bytes();
3373            write_counted(
3374                writer,
3375                &(file_bytes.len() as u32).to_le_bytes(),
3376                &mut bytes_written,
3377            )?;
3378            write_counted(writer, file_bytes, &mut bytes_written)?;
3379
3380            // Name
3381            let name_bytes = c.name.as_bytes();
3382            write_counted(
3383                writer,
3384                &(name_bytes.len() as u32).to_le_bytes(),
3385                &mut bytes_written,
3386            )?;
3387            write_counted(writer, name_bytes, &mut bytes_written)?;
3388
3389            // Qualified name (V7 metadata; absent is encoded as length 0)
3390            let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
3391            write_counted(
3392                writer,
3393                &(qualified_name_bytes.len() as u32).to_le_bytes(),
3394                &mut bytes_written,
3395            )?;
3396            write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
3397
3398            // Kind (1 byte)
3399            write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
3400
3401            // Lines + exported
3402            write_counted(
3403                writer,
3404                &(c.start_line as u32).to_le_bytes(),
3405                &mut bytes_written,
3406            )?;
3407            write_counted(
3408                writer,
3409                &(c.end_line as u32).to_le_bytes(),
3410                &mut bytes_written,
3411            )?;
3412            write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
3413
3414            // Snippet
3415            let snippet_bytes = c.snippet.as_bytes();
3416            write_counted(
3417                writer,
3418                &(snippet_bytes.len() as u32).to_le_bytes(),
3419                &mut bytes_written,
3420            )?;
3421            write_counted(writer, snippet_bytes, &mut bytes_written)?;
3422
3423            // Embed text
3424            let embed_bytes = c.embed_text.as_bytes();
3425            write_counted(
3426                writer,
3427                &(embed_bytes.len() as u32).to_le_bytes(),
3428                &mut bytes_written,
3429            )?;
3430            write_counted(writer, embed_bytes, &mut bytes_written)?;
3431
3432            // Vector (f32 array)
3433            for &val in &entry.vector {
3434                write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
3435            }
3436        }
3437
3438        Ok(bytes_written)
3439    }
3440
3441    /// Deserialize the index from bytes
3442    pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
3443        debug_assert!(current_canonical_root.is_absolute());
3444        if data.len() < HEADER_BYTES_V1 {
3445            return Err("data too short".to_string());
3446        }
3447
3448        Self::from_reader_after_version(
3449            Cursor::new(&data[1..]),
3450            data[0],
3451            current_canonical_root,
3452            Some(data.len()),
3453            1,
3454        )
3455    }
3456
3457    fn from_reader_after_version<R: Read>(
3458        reader: R,
3459        version: u8,
3460        current_canonical_root: &Path,
3461        total_len: Option<usize>,
3462        bytes_read: usize,
3463    ) -> Result<Self, String> {
3464        debug_assert!(current_canonical_root.is_absolute());
3465        let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
3466
3467        if version != SEMANTIC_INDEX_VERSION_V1
3468            && version != SEMANTIC_INDEX_VERSION_V2
3469            && version != SEMANTIC_INDEX_VERSION_V3
3470            && version != SEMANTIC_INDEX_VERSION_V4
3471            && version != SEMANTIC_INDEX_VERSION_V5
3472            && version != SEMANTIC_INDEX_VERSION_V6
3473            && version != SEMANTIC_INDEX_VERSION_V7
3474        {
3475            return Err(format!("unsupported version: {}", version));
3476        }
3477        // V2 and newer share the same header layout (V3/V4/V5 only differ from
3478        // V2 in the per-mtime entry layout): version(1) + dimension(4) +
3479        // entry_count(4) + fingerprint_len(4) + fingerprint bytes.
3480        if (version == SEMANTIC_INDEX_VERSION_V2
3481            || version == SEMANTIC_INDEX_VERSION_V3
3482            || version == SEMANTIC_INDEX_VERSION_V4
3483            || version == SEMANTIC_INDEX_VERSION_V5
3484            || version == SEMANTIC_INDEX_VERSION_V6
3485            || version == SEMANTIC_INDEX_VERSION_V7)
3486            && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
3487        {
3488            return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
3489        }
3490
3491        let dimension = read_u32_stream(&mut reader)? as usize;
3492        let entry_count = read_u32_stream(&mut reader)? as usize;
3493        validate_embedding_dimension(dimension)?;
3494        if entry_count > MAX_ENTRIES {
3495            return Err(format!("too many semantic index entries: {}", entry_count));
3496        }
3497
3498        // Fingerprint handling:
3499        //   - V1: no fingerprint field at all.
3500        //   - V2: fingerprint_len + fingerprint bytes; always present (writer
3501        //     only emitted V2 when fingerprint was Some).
3502        //   - V3+: fingerprint_len always present; fingerprint_len==0 ⇒ None.
3503        let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
3504            || version == SEMANTIC_INDEX_VERSION_V3
3505            || version == SEMANTIC_INDEX_VERSION_V4
3506            || version == SEMANTIC_INDEX_VERSION_V5
3507            || version == SEMANTIC_INDEX_VERSION_V6
3508            || version == SEMANTIC_INDEX_VERSION_V7;
3509        let fingerprint = if has_fingerprint_field {
3510            let fingerprint_len = read_u32_stream(&mut reader)? as usize;
3511            if total_len
3512                .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
3513            {
3514                return Err("unexpected end of data reading fingerprint".to_string());
3515            }
3516            if fingerprint_len == 0 {
3517                None
3518            } else {
3519                let mut raw = vec![0u8; fingerprint_len];
3520                read_exact_stream(
3521                    &mut reader,
3522                    &mut raw,
3523                    "unexpected end of data reading fingerprint",
3524                )?;
3525                let raw = String::from_utf8_lossy(&raw).to_string();
3526                Some(
3527                    serde_json::from_str::<SemanticIndexFingerprint>(&raw)
3528                        .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
3529                )
3530            }
3531        } else {
3532            None
3533        };
3534
3535        // File mtimes
3536        let mtime_count = read_u32_stream(&mut reader)? as usize;
3537        if mtime_count > MAX_ENTRIES {
3538            return Err(format!("too many semantic file mtimes: {}", mtime_count));
3539        }
3540
3541        let vector_bytes = entry_count
3542            .checked_mul(dimension)
3543            .and_then(|count| count.checked_mul(F32_BYTES))
3544            .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
3545        if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
3546            return Err("semantic index vectors exceed available data".to_string());
3547        }
3548
3549        let mut file_mtimes = HashMap::with_capacity(mtime_count);
3550        let mut file_sizes = HashMap::with_capacity(mtime_count);
3551        let mut file_hashes = HashMap::with_capacity(mtime_count);
3552        for _ in 0..mtime_count {
3553            let path = read_string_stream(&mut reader, total_len)?;
3554            let secs = read_u64_stream(&mut reader)?;
3555            // V3+ persists subsec_nanos alongside secs so staleness checks
3556            // survive restart round-trips. V1/V2 load with 0 nanos, which
3557            // causes one rebuild on upgrade (they never matched live APFS
3558            // mtimes anyway — the bug v0.15.2 fixes). After that rebuild,
3559            // the cache is persisted as V3 and stabilises.
3560            let nanos = if version == SEMANTIC_INDEX_VERSION_V3
3561                || version == SEMANTIC_INDEX_VERSION_V4
3562                || version == SEMANTIC_INDEX_VERSION_V5
3563                || version == SEMANTIC_INDEX_VERSION_V6
3564                || version == SEMANTIC_INDEX_VERSION_V7
3565            {
3566                read_u32_stream(&mut reader)?
3567            } else {
3568                0
3569            };
3570            let size = if version == SEMANTIC_INDEX_VERSION_V5
3571                || version == SEMANTIC_INDEX_VERSION_V6
3572                || version == SEMANTIC_INDEX_VERSION_V7
3573            {
3574                read_u64_stream(&mut reader)?
3575            } else {
3576                0
3577            };
3578            let content_hash =
3579                if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
3580                    let mut hash_bytes = [0u8; 32];
3581                    read_exact_stream(
3582                        &mut reader,
3583                        &mut hash_bytes,
3584                        "unexpected end of data reading content hash",
3585                    )?;
3586                    blake3::Hash::from_bytes(hash_bytes)
3587                } else {
3588                    cache_freshness::zero_hash()
3589                };
3590            // Hardening against corrupt / maliciously crafted cache files
3591            // (v0.15.2). `Duration::new(secs, nanos)` can panic when the
3592            // nanosecond carry overflows the second counter, and
3593            // `SystemTime + Duration` can panic on carry past the platform's
3594            // upper bound. Explicit validation keeps a corrupted semantic.bin
3595            // from taking down the whole aft process.
3596            if nanos >= 1_000_000_000 {
3597                return Err(format!(
3598                    "invalid semantic mtime: nanos {} >= 1_000_000_000",
3599                    nanos
3600                ));
3601            }
3602            let duration = std::time::Duration::new(secs, nanos);
3603            let mtime = SystemTime::UNIX_EPOCH
3604                .checked_add(duration)
3605                .ok_or_else(|| {
3606                    format!(
3607                        "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
3608                        secs, nanos
3609                    )
3610                })?;
3611            let path = if version == SEMANTIC_INDEX_VERSION_V6
3612                || version == SEMANTIC_INDEX_VERSION_V7
3613            {
3614                cached_path_under_root(current_canonical_root, &PathBuf::from(path))
3615                    .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
3616            } else {
3617                PathBuf::from(path)
3618            };
3619            file_mtimes.insert(path.clone(), mtime);
3620            file_sizes.insert(path.clone(), size);
3621            file_hashes.insert(path, content_hash);
3622        }
3623
3624        // Entries
3625        let mut entries = Vec::with_capacity(entry_count);
3626        for _ in 0..entry_count {
3627            let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
3628            let file = if version == SEMANTIC_INDEX_VERSION_V6
3629                || version == SEMANTIC_INDEX_VERSION_V7
3630            {
3631                cached_path_under_root(current_canonical_root, &raw_file)
3632                    .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
3633            } else {
3634                raw_file
3635            };
3636            let name = read_string_stream(&mut reader, total_len)?;
3637            let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
3638                let qualified_name = read_string_stream(&mut reader, total_len)?;
3639                if qualified_name.is_empty() {
3640                    None
3641                } else {
3642                    Some(qualified_name)
3643                }
3644            } else {
3645                None
3646            };
3647
3648            let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
3649
3650            let start_line = read_u32_stream(&mut reader)?;
3651            let end_line = read_u32_stream(&mut reader)?;
3652
3653            let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
3654
3655            let snippet = read_string_stream(&mut reader, total_len)?;
3656            let embed_text = read_string_stream(&mut reader, total_len)?;
3657
3658            // Vector
3659            let vec_bytes = dimension
3660                .checked_mul(F32_BYTES)
3661                .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
3662            if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
3663                return Err("unexpected end of data reading vector".to_string());
3664            }
3665            let mut vector = Vec::with_capacity(dimension);
3666            for _ in 0..dimension {
3667                let mut bytes = [0u8; F32_BYTES];
3668                read_exact_stream(
3669                    &mut reader,
3670                    &mut bytes,
3671                    "unexpected end of data reading vector",
3672                )?;
3673                vector.push(f32::from_le_bytes(bytes));
3674            }
3675
3676            entries.push(EmbeddingEntry::new(
3677                SemanticChunk {
3678                    file,
3679                    name,
3680                    qualified_name,
3681                    kind,
3682                    start_line,
3683                    end_line,
3684                    exported,
3685                    embed_text,
3686                    snippet,
3687                },
3688                vector,
3689            ));
3690        }
3691
3692        if entries.len() != entry_count {
3693            return Err(format!(
3694                "semantic cache entry count drift: header={} decoded={}",
3695                entry_count,
3696                entries.len()
3697            ));
3698        }
3699        for entry in &entries {
3700            if !file_mtimes.contains_key(&entry.chunk.file) {
3701                return Err(format!(
3702                    "semantic cache metadata missing for entry file {}",
3703                    entry.chunk.file.display()
3704                ));
3705            }
3706        }
3707
3708        let any_missing_sizes = file_mtimes
3709            .keys()
3710            .any(|path| !file_sizes.contains_key(path));
3711        Ok(Self {
3712            entries,
3713            file_mtimes,
3714            file_sizes,
3715            any_missing_sizes,
3716            file_hashes,
3717            dimension,
3718            fingerprint,
3719            project_root: current_canonical_root.to_path_buf(),
3720            deferred_files: HashSet::new(),
3721            shared_base: None,
3722        })
3723    }
3724}
3725
3726fn write_counted<W: Write>(
3727    writer: &mut W,
3728    bytes: &[u8],
3729    bytes_written: &mut usize,
3730) -> io::Result<()> {
3731    writer.write_all(bytes)?;
3732    *bytes_written = bytes_written.saturating_add(bytes.len());
3733    Ok(())
3734}
3735
3736struct CountingReader<R> {
3737    inner: R,
3738    bytes_read: usize,
3739}
3740
3741impl<R> CountingReader<R> {
3742    fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
3743        Self { inner, bytes_read }
3744    }
3745
3746    fn bytes_read(&self) -> usize {
3747        self.bytes_read
3748    }
3749}
3750
3751impl<R: Read> Read for CountingReader<R> {
3752    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
3753        let read = self.inner.read(buf)?;
3754        self.bytes_read = self.bytes_read.saturating_add(read);
3755        Ok(read)
3756    }
3757}
3758
3759fn read_exact_stream<R: Read>(
3760    reader: &mut CountingReader<R>,
3761    buf: &mut [u8],
3762    eof_message: &'static str,
3763) -> Result<(), String> {
3764    reader.read_exact(buf).map_err(|error| {
3765        if error.kind() == io::ErrorKind::UnexpectedEof {
3766            eof_message.to_string()
3767        } else {
3768            format!("{eof_message}: {error}")
3769        }
3770    })
3771}
3772
3773fn read_u8_stream<R: Read>(
3774    reader: &mut CountingReader<R>,
3775    eof_message: &'static str,
3776) -> Result<u8, String> {
3777    let mut bytes = [0u8; 1];
3778    read_exact_stream(reader, &mut bytes, eof_message)?;
3779    Ok(bytes[0])
3780}
3781
3782fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
3783    let mut bytes = [0u8; 4];
3784    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
3785    Ok(u32::from_le_bytes(bytes))
3786}
3787
3788fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
3789    let mut bytes = [0u8; 8];
3790    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
3791    Ok(u64::from_le_bytes(bytes))
3792}
3793
3794fn read_string_stream<R: Read>(
3795    reader: &mut CountingReader<R>,
3796    total_len: Option<usize>,
3797) -> Result<String, String> {
3798    let len = read_u32_stream(reader)? as usize;
3799    if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
3800        return Err("unexpected end of data reading string".to_string());
3801    }
3802    let mut bytes = vec![0u8; len];
3803    read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
3804    Ok(String::from_utf8_lossy(&bytes).to_string())
3805}
3806
3807struct SourceLineCache<'a> {
3808    lines: Vec<&'a str>,
3809    line_starts: Vec<usize>,
3810}
3811
3812impl<'a> SourceLineCache<'a> {
3813    fn new(source: &'a str) -> Self {
3814        let lines: Vec<&'a str> = source.lines().collect();
3815        let mut line_starts = Vec::with_capacity(lines.len());
3816        let bytes = source.as_bytes();
3817        let mut offset = 0usize;
3818        for line in &lines {
3819            line_starts.push(offset);
3820            offset += line.len();
3821            if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
3822                offset += 2;
3823            } else if bytes.get(offset) == Some(&b'\n') {
3824                offset += 1;
3825            }
3826        }
3827        Self { lines, line_starts }
3828    }
3829
3830    fn len(&self) -> usize {
3831        debug_assert_eq!(self.lines.len(), self.line_starts.len());
3832        self.line_starts.len()
3833    }
3834}
3835
3836/// Build enriched embedding text from a symbol with cAST-style context
3837fn build_embed_text_with_lines(
3838    symbol: &Symbol,
3839    line_cache: &SourceLineCache<'_>,
3840    file: &Path,
3841    project_root: &Path,
3842) -> String {
3843    let relative = file
3844        .strip_prefix(project_root)
3845        .unwrap_or(file)
3846        .to_string_lossy();
3847
3848    let kind_label = match &symbol.kind {
3849        SymbolKind::Function => "function",
3850        SymbolKind::Class => "class",
3851        SymbolKind::Method => "method",
3852        SymbolKind::Struct => "struct",
3853        SymbolKind::Interface => "interface",
3854        SymbolKind::Enum => "enum",
3855        SymbolKind::TypeAlias => "type",
3856        SymbolKind::Variable => "variable",
3857        SymbolKind::Heading => "heading",
3858        SymbolKind::FileSummary => "file-summary",
3859    };
3860
3861    // Build: "file:relative/path kind:function name:validateAuth signature:fn validateAuth(token: &str) -> bool"
3862    let name = &symbol.name;
3863    let mut text = format!(
3864        "name:{name} file:{} kind:{} name:{name}",
3865        relative, kind_label
3866    );
3867
3868    if let Some(sig) = &symbol.signature {
3869        // Cap the signature: structured parsers (e.g. YAML/Kubernetes) pack
3870        // entire inline scripts (CronJob/Job `command:` bodies, multi-KB) into
3871        // the signature. Appending it unbounded produces a single embed_text
3872        // that overflows the embedding backend's physical batch (e.g. a
3873        // llama.cpp server's 512-token cap), aborting the whole index build
3874        // and silently degrading every search to lexical. 400 chars keeps the
3875        // identifying head of the signature without blowing the budget.
3876        text.push_str(&format!(" signature:{}", truncate_chars(sig, 400)));
3877    }
3878
3879    // Add body snippet (first ~300 chars of symbol body)
3880    let start = (symbol.range.start_line as usize).min(line_cache.len());
3881    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
3882    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
3883    if start < end {
3884        let body: String = line_cache.lines[start..end]
3885            .iter()
3886            .take(15) // max 15 lines
3887            .copied()
3888            .collect::<Vec<&str>>()
3889            .join("\n");
3890        let snippet = if body.len() > 300 {
3891            format!("{}...", &body[..body.floor_char_boundary(300)])
3892        } else {
3893            body
3894        };
3895        text.push_str(&format!(" body:{}", snippet));
3896    }
3897
3898    // Final defense-in-depth clamp: no single embed_text may exceed the
3899    // backend's per-input budget regardless of which field grew. Most
3900    // backends cap a physical batch around 512 tokens; ~1600 chars stays
3901    // comfortably under that for typical English/code (≈4 chars/token).
3902    truncate_chars(&text, MAX_EMBED_TEXT_CHARS)
3903}
3904
3905#[cfg(test)]
3906fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
3907    let line_cache = SourceLineCache::new(source);
3908    build_embed_text_with_lines(symbol, &line_cache, file, project_root)
3909}
3910
3911/// Upper bound on characters in a single chunk's `embed_text`. Keeps any one
3912/// input below typical embedding-backend physical batch limits (~512 tokens)
3913/// so an oversized symbol cannot abort the whole index build.
3914const MAX_EMBED_TEXT_CHARS: usize = 1600;
3915
3916fn truncate_chars(value: &str, max_chars: usize) -> String {
3917    value.chars().take(max_chars).collect()
3918}
3919
3920fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
3921    let Some((start, first)) = line_cache
3922        .lines
3923        .iter()
3924        .enumerate()
3925        .find(|(_, line)| !line.trim().is_empty())
3926    else {
3927        return String::new();
3928    };
3929
3930    let trimmed = first.trim_start();
3931    if trimmed.starts_with("/**") {
3932        let mut comment = Vec::new();
3933        for line in line_cache.lines.iter().skip(start) {
3934            comment.push(*line);
3935            if line.contains("*/") {
3936                break;
3937            }
3938        }
3939        return truncate_chars(&comment.join("\n"), 200);
3940    }
3941
3942    if trimmed.starts_with("///") || trimmed.starts_with("//!") {
3943        let comment = line_cache
3944            .lines
3945            .iter()
3946            .skip(start)
3947            .take_while(|line| {
3948                let trimmed = line.trim_start();
3949                trimmed.starts_with("///") || trimmed.starts_with("//!")
3950            })
3951            .copied()
3952            .collect::<Vec<_>>()
3953            .join("\n");
3954        return truncate_chars(&comment, 200);
3955    }
3956
3957    String::new()
3958}
3959
3960pub fn build_file_summary_chunk(
3961    file: &Path,
3962    project_root: &Path,
3963    source: &str,
3964    top_exports: &[&str],
3965    top_export_signatures: &[Option<&str>],
3966) -> SemanticChunk {
3967    let line_cache = SourceLineCache::new(source);
3968    build_file_summary_chunk_with_lines(
3969        file,
3970        project_root,
3971        &line_cache,
3972        top_exports,
3973        top_export_signatures,
3974    )
3975}
3976
3977fn build_file_summary_chunk_with_lines(
3978    file: &Path,
3979    project_root: &Path,
3980    line_cache: &SourceLineCache<'_>,
3981    top_exports: &[&str],
3982    top_export_signatures: &[Option<&str>],
3983) -> SemanticChunk {
3984    let relative = file.strip_prefix(project_root).unwrap_or(file);
3985    let rel_path = relative.to_string_lossy();
3986    let parent_dir = relative
3987        .parent()
3988        .map(|parent| parent.to_string_lossy().to_string())
3989        .unwrap_or_default();
3990    let name = file
3991        .file_stem()
3992        .map(|stem| stem.to_string_lossy().to_string())
3993        .unwrap_or_default();
3994    let doc = first_leading_doc_comment(line_cache);
3995    let exports = top_exports
3996        .iter()
3997        .take(5)
3998        .copied()
3999        .collect::<Vec<_>>()
4000        .join(",");
4001    let snippet = if doc.is_empty() {
4002        top_export_signatures
4003            .first()
4004            .and_then(|signature| signature.as_deref())
4005            .map(|signature| truncate_chars(signature, 200))
4006            .unwrap_or_default()
4007    } else {
4008        doc.clone()
4009    };
4010
4011    SemanticChunk {
4012        file: file.to_path_buf(),
4013        name,
4014        qualified_name: None,
4015        kind: SymbolKind::FileSummary,
4016        start_line: 0,
4017        end_line: 0,
4018        exported: false,
4019        embed_text: truncate_chars(
4020            &format!(
4021                "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
4022                file.file_stem()
4023                    .map(|stem| stem.to_string_lossy().to_string())
4024                    .unwrap_or_default()
4025            ),
4026            MAX_EMBED_TEXT_CHARS,
4027        ),
4028        snippet,
4029    }
4030}
4031
4032pub fn is_semantic_indexed_extension(path: &Path) -> bool {
4033    if path.file_name().and_then(|name| name.to_str()) == Some("Jenkinsfile") {
4034        return true;
4035    }
4036
4037    matches!(
4038        path.extension().and_then(|extension| extension.to_str()),
4039        Some(
4040            "ts" | "tsx"
4041                | "js"
4042                | "jsx"
4043                | "py"
4044                | "rs"
4045                | "go"
4046                | "c"
4047                | "h"
4048                | "cc"
4049                | "cpp"
4050                | "cxx"
4051                | "hpp"
4052                | "hh"
4053                | "zig"
4054                | "cs"
4055                | "sh"
4056                | "bash"
4057                | "zsh"
4058                | "inc"
4059                | "php"
4060                | "sol"
4061                | "scss"
4062                | "vue"
4063                | "yaml"
4064                | "yml"
4065                | "pas"
4066                | "pp"
4067                | "dpr"
4068                | "dpk"
4069                | "lpr"
4070                | "java"
4071                | "kt"
4072                | "kts"
4073                | "rb"
4074                | "swift"
4075                | "scala"
4076                | "sc"
4077                | "lua"
4078                | "pl"
4079                | "pm"
4080                | "t"
4081                | "r"
4082                | "R"
4083                | "groovy"
4084                | "gvy"
4085                | "gy"
4086                | "gsh"
4087                | "gradle"
4088                | "m"
4089                | "mm",
4090        )
4091    )
4092}
4093
4094fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
4095    if let Ok(canonical) = fs::canonicalize(path) {
4096        return canonical;
4097    }
4098
4099    let Some(parent) = path.parent() else {
4100        return path.to_path_buf();
4101    };
4102    let Some(file_name) = path.file_name() else {
4103        return path.to_path_buf();
4104    };
4105
4106    fs::canonicalize(parent)
4107        .map(|canonical_parent| canonical_parent.join(file_name))
4108        .unwrap_or_else(|_| path.to_path_buf())
4109}
4110
4111/// Files larger than this are skipped for semantic chunking. The read +
4112/// tree-sitter parse is transiently O(file size) (tree-sitter can use several×
4113/// the source bytes), and `par_iter` collection parses many files at once, so an
4114/// unbounded read here is an OOM vector on a repo with a few multi-MB generated/
4115/// vendored/minified files. A file this large yields almost no useful embedding
4116/// anyway (each chunk's embed_text is clamped to MAX_EMBED_TEXT_CHARS), so we
4117/// track it (0 chunks) instead of reading it — freshness then skips it on later
4118/// refreshes. 4 MiB keeps essentially all hand-written source while capping the
4119/// pathological tail.
4120const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
4121
4122fn collect_semantic_file(
4123    project_root: &Path,
4124    file: &Path,
4125    phases: &mut SemanticCollectPhaseTimings,
4126) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
4127    let read_hash_started = Instant::now();
4128    let read_result = (|| {
4129        let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
4130        if !metadata.is_file() {
4131            return Err("not a regular file".to_string());
4132        }
4133        let mtime = metadata.modified().map_err(|error| error.to_string())?;
4134        let size = metadata.len();
4135
4136        if !is_semantic_indexed_extension(file) {
4137            return Err("unsupported file extension".to_string());
4138        }
4139        let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4140
4141        let mut indexed_metadata = IndexedFileMetadata {
4142            mtime,
4143            size,
4144            content_hash: cache_freshness::zero_hash(),
4145        };
4146
4147        // OOM backstop: skip oversized files before the read + parse (tracked with
4148        // zero chunks by the caller, so freshness won't re-read them every refresh).
4149        if size > MAX_SEMANTIC_FILE_BYTES {
4150            return Ok((indexed_metadata, lang, None));
4151        }
4152
4153        let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4154        indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
4155            cache_freshness::hash_bytes(source.as_bytes())
4156        } else {
4157            cache_freshness::zero_hash()
4158        };
4159        Ok((indexed_metadata, lang, Some(source)))
4160    })();
4161    phases.read_hash += read_hash_started.elapsed();
4162    let (indexed_metadata, lang, source) = read_result?;
4163    let Some(source) = source else {
4164        return Ok((indexed_metadata, Vec::new()));
4165    };
4166
4167    let chunks = collect_file_chunks_from_source_timed(project_root, file, lang, &source, phases)?;
4168    Ok((indexed_metadata, chunks))
4169}
4170
4171#[cfg(test)]
4172fn collect_file_chunks(project_root: &Path, file: &Path) -> Result<Vec<SemanticChunk>, String> {
4173    if !is_semantic_indexed_extension(file) {
4174        return Err("unsupported file extension".to_string());
4175    }
4176    let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
4177    // OOM backstop: skip oversized files before the read + parse (tracked with
4178    // zero chunks by the caller, so freshness won't re-read them every refresh).
4179    if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
4180        return Ok(Vec::new());
4181    }
4182    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
4183    collect_file_chunks_from_source(project_root, file, lang, &source)
4184}
4185
4186#[cfg(test)]
4187fn collect_file_chunks_from_source(
4188    project_root: &Path,
4189    file: &Path,
4190    lang: crate::parser::LangId,
4191    source: &str,
4192) -> Result<Vec<SemanticChunk>, String> {
4193    collect_file_chunks_from_source_timed(
4194        project_root,
4195        file,
4196        lang,
4197        source,
4198        &mut SemanticCollectPhaseTimings::default(),
4199    )
4200}
4201
4202fn collect_file_chunks_from_source_timed(
4203    project_root: &Path,
4204    file: &Path,
4205    lang: crate::parser::LangId,
4206    source: &str,
4207    phases: &mut SemanticCollectPhaseTimings,
4208) -> Result<Vec<SemanticChunk>, String> {
4209    let parse_started = Instant::now();
4210    let tree_result =
4211        parse_source_with_cached_parser(file, source, lang).map_err(|error| error.to_string());
4212    phases.parse += parse_started.elapsed();
4213    let tree = tree_result?;
4214
4215    let extract_started = Instant::now();
4216    let symbols_result =
4217        extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string());
4218    phases.extract += extract_started.elapsed();
4219    let symbols = symbols_result?;
4220
4221    let build_started = Instant::now();
4222    let chunks = symbols_to_chunks(file, &symbols, source, project_root);
4223    phases.build += build_started.elapsed();
4224    Ok(chunks)
4225}
4226
4227/// Build a display snippet from a symbol's source
4228fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
4229    let start = (symbol.range.start_line as usize).min(line_cache.len());
4230    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
4231    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
4232    if start < end {
4233        let snippet_lines: Vec<&str> = line_cache.lines[start..end]
4234            .iter()
4235            .take(5)
4236            .copied()
4237            .collect();
4238        let mut snippet = snippet_lines.join("\n");
4239        if end - start > 5 {
4240            snippet.push_str("\n  ...");
4241        }
4242        if snippet.len() > 300 {
4243            snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
4244        }
4245        snippet
4246    } else {
4247        String::new()
4248    }
4249}
4250
4251#[cfg(test)]
4252fn build_snippet(symbol: &Symbol, source: &str) -> String {
4253    let line_cache = SourceLineCache::new(source);
4254    build_snippet_with_lines(symbol, &line_cache)
4255}
4256
4257fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
4258    let mut parts = symbol
4259        .scope_chain
4260        .iter()
4261        .filter(|part| !part.is_empty())
4262        .cloned()
4263        .collect::<Vec<_>>();
4264    if !symbol.name.is_empty() {
4265        parts.push(symbol.name.clone());
4266    }
4267    (!parts.is_empty()).then(|| parts.join("."))
4268}
4269
4270/// Convert symbols to semantic chunks with enriched context
4271fn symbols_to_chunks(
4272    file: &Path,
4273    symbols: &[Symbol],
4274    source: &str,
4275    project_root: &Path,
4276) -> Vec<SemanticChunk> {
4277    let line_cache = SourceLineCache::new(source);
4278    let mut chunks = Vec::new();
4279    let top_exports_with_signatures = symbols
4280        .iter()
4281        .filter(|symbol| {
4282            symbol.exported
4283                && symbol.parent.is_none()
4284                && !matches!(symbol.kind, SymbolKind::Heading)
4285        })
4286        .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
4287        .collect::<Vec<_>>();
4288
4289    let has_only_headings = !symbols.is_empty()
4290        && symbols
4291            .iter()
4292            .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
4293    if top_exports_with_signatures.len() <= 2 && !has_only_headings {
4294        let top_exports = top_exports_with_signatures
4295            .iter()
4296            .map(|(name, _)| *name)
4297            .collect::<Vec<_>>();
4298        let top_export_signatures = top_exports_with_signatures
4299            .iter()
4300            .map(|(_, signature)| *signature)
4301            .collect::<Vec<_>>();
4302        chunks.push(build_file_summary_chunk_with_lines(
4303            file,
4304            project_root,
4305            &line_cache,
4306            &top_exports,
4307            &top_export_signatures,
4308        ));
4309    }
4310
4311    for symbol in symbols {
4312        // Skip Markdown / HTML heading chunks: empirically they dominate result
4313        // lists even for code-shaped queries because heading prose embeds well.
4314        // Agents querying for code lose the actual matches under doc noise.
4315        // README/docs queries are still served by grep on the same files.
4316        if matches!(symbol.kind, SymbolKind::Heading) {
4317            continue;
4318        }
4319
4320        // Skip very small symbols (single-line variables, etc.)
4321        let line_count = symbol
4322            .range
4323            .end_line
4324            .saturating_sub(symbol.range.start_line)
4325            + 1;
4326        if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
4327            continue;
4328        }
4329
4330        let embed_text = build_embed_text_with_lines(symbol, &line_cache, file, project_root);
4331        let snippet = build_snippet_with_lines(symbol, &line_cache);
4332
4333        chunks.push(SemanticChunk {
4334            file: file.to_path_buf(),
4335            name: symbol.name.clone(),
4336            qualified_name: qualified_name_for_symbol(symbol),
4337            kind: symbol.kind.clone(),
4338            start_line: symbol.range.start_line,
4339            end_line: symbol.range.end_line,
4340            exported: symbol.exported,
4341            embed_text,
4342            snippet,
4343        });
4344
4345        // Note: Nested symbols are handled separately by the outline system
4346        // Each symbol is indexed individually
4347    }
4348
4349    chunks
4350}
4351
4352fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
4353    b.0.partial_cmp(&a.0)
4354        .unwrap_or(std::cmp::Ordering::Equal)
4355        .then_with(|| a.1.cmp(&b.1))
4356}
4357
4358/// Compute an embedding's L2 norm for its in-memory search cache.
4359fn vector_norm(vector: &[f32]) -> f32 {
4360    vector.iter().map(|value| value * value).sum::<f32>().sqrt()
4361}
4362
4363fn dot_product(a: &[f32], b: &[f32]) -> f32 {
4364    a.iter().zip(b).map(|(a, b)| a * b).sum::<f32>()
4365}
4366
4367/// Cosine similarity reference retained for focused unit tests.
4368#[cfg(test)]
4369fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
4370    if a.len() != b.len() {
4371        return 0.0;
4372    }
4373
4374    let mut dot = 0.0f32;
4375    let mut norm_a = 0.0f32;
4376    let mut norm_b = 0.0f32;
4377
4378    for i in 0..a.len() {
4379        dot += a[i] * b[i];
4380        norm_a += a[i] * a[i];
4381        norm_b += b[i] * b[i];
4382    }
4383
4384    let denom = norm_a.sqrt() * norm_b.sqrt();
4385    if denom == 0.0 {
4386        0.0
4387    } else {
4388        dot / denom
4389    }
4390}
4391
4392// Serialization helpers
4393fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
4394    match kind {
4395        SymbolKind::Function => 0,
4396        SymbolKind::Class => 1,
4397        SymbolKind::Method => 2,
4398        SymbolKind::Struct => 3,
4399        SymbolKind::Interface => 4,
4400        SymbolKind::Enum => 5,
4401        SymbolKind::TypeAlias => 6,
4402        SymbolKind::Variable => 7,
4403        SymbolKind::Heading => 8,
4404        SymbolKind::FileSummary => 9,
4405    }
4406}
4407
4408fn u8_to_symbol_kind(v: u8) -> SymbolKind {
4409    match v {
4410        0 => SymbolKind::Function,
4411        1 => SymbolKind::Class,
4412        2 => SymbolKind::Method,
4413        3 => SymbolKind::Struct,
4414        4 => SymbolKind::Interface,
4415        5 => SymbolKind::Enum,
4416        6 => SymbolKind::TypeAlias,
4417        7 => SymbolKind::Variable,
4418        8 => SymbolKind::Heading,
4419        9 => SymbolKind::FileSummary,
4420        _ => SymbolKind::Heading,
4421    }
4422}
4423
4424#[cfg(test)]
4425mod tests {
4426    use super::*;
4427    use crate::config::{SemanticBackend, SemanticBackendConfig};
4428    use crate::parser::FileParser;
4429    use std::io::{Read, Write};
4430    use std::net::TcpListener;
4431    use std::thread;
4432
4433    // Only the unix-gated baseline test consumes these (see its comment for
4434    // why Windows cannot reproduce the hash); keep Windows -D warnings clean.
4435    #[cfg(unix)]
4436    const RUST_QUERY_BASELINE_OUTPUT_HASH: &str =
4437        "36315439db74ed8e186076f79ed261079b2b13a4443ed4272861a2518c78d98b";
4438
4439    #[cfg(unix)]
4440    fn rust_fixture_semantic_output_fingerprint(project_root: &Path) -> (usize, usize, String) {
4441        let fixture_root = project_root.join("tests/fixtures");
4442        // Re-materialize the fixtures with LF bytes before collecting: Windows
4443        // checkouts (core.autocrlf) hand collect_chunks CRLF sources, and the
4444        // extra byte per line shifts snippet/embed-text cap boundaries — so
4445        // post-hoc \r stripping cannot reproduce the LF-computed baseline.
4446        let lf_root = tempfile::tempdir().expect("lf fixture root");
4447        let fixture_files = [
4448            "imports_rs.rs",
4449            "member_rs.rs",
4450            "sample.rs",
4451            "structure_rs.rs",
4452        ]
4453        .map(|name| {
4454            let source = std::fs::read_to_string(fixture_root.join(name))
4455                .expect("read fixture")
4456                .replace("\r\n", "\n");
4457            // Preserve the tests/fixtures/<name> layout: chunk identity fields
4458            // (relative path, qualified name, embed-text header) derive from the
4459            // path relative to the project root, so a flat layout re-keys them.
4460            let path = lf_root.path().join("tests/fixtures").join(name);
4461            std::fs::create_dir_all(path.parent().unwrap()).expect("fixture dirs");
4462            std::fs::write(&path, source).expect("write LF fixture");
4463            path
4464        });
4465        let project_root = lf_root.path();
4466        let (chunks, _) = SemanticIndex::collect_chunks(project_root, &fixture_files);
4467        let normalized = chunks
4468            .iter()
4469            .map(|chunk| {
4470                (
4471                    chunk
4472                        .file
4473                        .strip_prefix(project_root)
4474                        .unwrap()
4475                        .to_string_lossy()
4476                        .replace('\\', "/"),
4477                    &chunk.name,
4478                    &chunk.qualified_name,
4479                    &chunk.kind,
4480                    chunk.start_line,
4481                    chunk.end_line,
4482                    chunk.exported,
4483                    &chunk.embed_text,
4484                    &chunk.snippet,
4485                )
4486            })
4487            .collect::<Vec<_>>();
4488        let output = format!("{normalized:#?}");
4489        (
4490            chunks.len(),
4491            output.len(),
4492            blake3::hash(output.as_bytes()).to_hex().to_string(),
4493        )
4494    }
4495
4496    // Unix-only: chunk embed text bakes the OS-native relative path into its
4497    // header (file-summary chunks), so a Windows run hashes "tests\fixtures\…"
4498    // and can never reproduce the unix-captured baseline even with LF-forced
4499    // sources. The property under test — the query-free Rust walk reproduces
4500    // the old RS_QUERY output byte-for-byte — is platform-independent and is
4501    // pinned where the baseline was captured.
4502    #[cfg(unix)]
4503    #[test]
4504    fn rust_semantic_fixture_output_matches_query_baseline() {
4505        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4506        let (_, _, output_hash) = rust_fixture_semantic_output_fingerprint(&project_root);
4507        assert_eq!(output_hash, RUST_QUERY_BASELINE_OUTPUT_HASH);
4508    }
4509
4510    #[test]
4511    #[ignore = "manual single-file semantic collect phase benchmark"]
4512    fn profile_rust_single_file_semantic_collect() {
4513        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4514        let workspace_root = crate_root
4515            .parent()
4516            .and_then(Path::parent)
4517            .expect("workspace root");
4518        let files = [
4519            workspace_root.join("crates/aft/src/bash_background/registry.rs"),
4520            workspace_root.join("crates/aft-tokenizer/src/claude_data.rs"),
4521        ];
4522
4523        for file in files {
4524            let source = fs::read_to_string(&file).expect("read benchmark source");
4525            for run in 1..=5 {
4526                let mut phases = SemanticCollectPhaseTimings::default();
4527                let started = Instant::now();
4528                let chunks = collect_file_chunks_from_source_timed(
4529                    workspace_root,
4530                    &file,
4531                    crate::parser::LangId::Rust,
4532                    &source,
4533                    &mut phases,
4534                )
4535                .unwrap();
4536                eprintln!(
4537                    "semantic single-file file={} bytes={} run={run}: total={:?} parse={:?} extract={:?} build={:?} chunks={}",
4538                    file.strip_prefix(workspace_root).unwrap().display(),
4539                    source.len(),
4540                    started.elapsed(),
4541                    phases.parse,
4542                    phases.extract,
4543                    phases.build,
4544                    chunks.len()
4545                );
4546            }
4547        }
4548    }
4549
4550    #[test]
4551    fn semantic_index_includes_php_inc_and_scss_extensions() {
4552        for file in ["partial.inc", "index.php", "styles.scss"] {
4553            assert!(
4554                is_semantic_indexed_extension(Path::new(file)),
4555                "{file} should be semantic-index eligible"
4556            );
4557        }
4558    }
4559
4560    #[test]
4561    fn semantic_index_includes_groovy_extensions_and_jenkinsfile() {
4562        for file in [
4563            "script.groovy",
4564            "script.gvy",
4565            "script.gy",
4566            "shell.gsh",
4567            "build.gradle",
4568            "Jenkinsfile",
4569        ] {
4570            assert!(
4571                is_semantic_indexed_extension(Path::new(file)),
4572                "{file} should be semantic-index eligible"
4573            );
4574        }
4575        assert!(is_semantic_indexed_extension(Path::new("build.gradle.kts")));
4576    }
4577
4578    #[test]
4579    fn transient_marker_round_trips_and_classifies() {
4580        // A marked transient error is recognized and the marker is stripped for
4581        // display, leaving a clean message.
4582        let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
4583        assert!(embedding_failure_is_transient(&marked));
4584        let clean = strip_transient_embedding_marker(&marked);
4585        assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
4586        assert!(clean.starts_with("openai compatible request failed:"));
4587
4588        // Permanent errors (HTTP 4xx, dimension mismatch) carry no marker and
4589        // are not classified transient — they must fail fast.
4590        for permanent in [
4591            "openai compatible request failed (HTTP 401): Unauthorized",
4592            "embedding dimension mismatch: index has 384, model returned 768",
4593            "too many files (>20000) for semantic indexing (max 20000)",
4594        ] {
4595            assert!(
4596                !embedding_failure_is_transient(permanent),
4597                "{permanent:?} must not be transient"
4598            );
4599            // Stripping a marker-free string is a no-op.
4600            assert_eq!(strip_transient_embedding_marker(permanent), permanent);
4601        }
4602    }
4603
4604    #[test]
4605    fn send_error_transience_separates_connect_timeout_from_4xx() {
4606        // 5xx / 429 are transient; other client errors are not.
4607        assert!(is_retryable_embedding_status(
4608            reqwest::StatusCode::INTERNAL_SERVER_ERROR
4609        ));
4610        assert!(is_retryable_embedding_status(
4611            reqwest::StatusCode::TOO_MANY_REQUESTS
4612        ));
4613        assert!(!is_retryable_embedding_status(
4614            reqwest::StatusCode::UNAUTHORIZED
4615        ));
4616        assert!(!is_retryable_embedding_status(
4617            reqwest::StatusCode::BAD_REQUEST
4618        ));
4619    }
4620
4621    #[test]
4622    fn local_backend_model_loading_body_is_transient() {
4623        // LM Studio / Ollama return a 4xx with a loading/unloaded message while
4624        // the model swaps; these must classify transient so the build self-heals.
4625        for body in [
4626            r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
4627            r#"{"error":"model is loading, please wait"}"#,
4628            r#"{"error":"Model not loaded"}"#,
4629            "Loading model into memory",
4630        ] {
4631            assert!(
4632                embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
4633                "{body:?} should be body-transient"
4634            );
4635        }
4636
4637        // A genuine 4xx misconfiguration body must NOT be treated as transient,
4638        // even when it happens to contain generic words from the old broad
4639        // substring matcher.
4640        for body in [
4641            r#"{"error":"invalid api key"}"#,
4642            r#"{"error":"model 'foo' not found"}"#,
4643            "Bad Request: unknown field",
4644            "Bad Request: invalid loading model option",
4645            r#"{"error":"unauthorized while model is being loaded by another account"}"#,
4646        ] {
4647            assert!(
4648                !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
4649                "{body:?} must not be body-transient"
4650            );
4651        }
4652
4653        assert!(
4654            !embedding_response_body_is_transient(
4655                reqwest::StatusCode::UNAUTHORIZED,
4656                r#"{"error":"model is loading, please wait"}"#
4657            ),
4658            "permanent auth failures must not become transient because of body text"
4659        );
4660    }
4661
4662    fn start_slow_embedding_server(
4663        expected_requests: usize,
4664        response_delay: Duration,
4665    ) -> (String, Arc<AtomicUsize>, thread::JoinHandle<()>) {
4666        let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow embedding server");
4667        listener
4668            .set_nonblocking(true)
4669            .expect("set slow server nonblocking");
4670        let addr = listener.local_addr().expect("slow embedding server addr");
4671        let requests = Arc::new(AtomicUsize::new(0));
4672        let requests_for_thread = Arc::clone(&requests);
4673        let handle = thread::spawn(move || {
4674            let deadline = Instant::now() + Duration::from_secs(10);
4675            let mut handlers = Vec::new();
4676            while requests_for_thread.load(Ordering::SeqCst) < expected_requests
4677                && Instant::now() < deadline
4678            {
4679                match listener.accept() {
4680                    Ok((mut stream, _)) => {
4681                        requests_for_thread.fetch_add(1, Ordering::SeqCst);
4682                        handlers.push(thread::spawn(move || {
4683                            let mut request = [0u8; 4096];
4684                            let _ = stream.read(&mut request);
4685                            thread::sleep(response_delay);
4686                            let body =
4687                                r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
4688                            let response = format!(
4689                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4690                                body.len(),
4691                                body
4692                            );
4693                            let _ = stream.write_all(response.as_bytes());
4694                        }));
4695                    }
4696                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
4697                        thread::sleep(Duration::from_millis(5));
4698                    }
4699                    Err(error) => panic!("accept slow embedding request: {error}"),
4700                }
4701            }
4702            for handler in handlers {
4703                handler.join().expect("slow embedding handler");
4704            }
4705        });
4706
4707        (format!("http://{addr}"), requests, handle)
4708    }
4709
4710    fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
4711    where
4712        F: Fn(String, String, String) -> String + Send + 'static,
4713    {
4714        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
4715        let addr = listener.local_addr().expect("local addr");
4716        let handle = thread::spawn(move || {
4717            let (mut stream, _) = listener.accept().expect("accept request");
4718            let mut buf = Vec::new();
4719            let mut chunk = [0u8; 4096];
4720            let mut header_end = None;
4721            let mut content_length = 0usize;
4722            loop {
4723                let n = stream.read(&mut chunk).expect("read request");
4724                if n == 0 {
4725                    break;
4726                }
4727                buf.extend_from_slice(&chunk[..n]);
4728                if header_end.is_none() {
4729                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
4730                        header_end = Some(pos + 4);
4731                        let headers = String::from_utf8_lossy(&buf[..pos + 4]);
4732                        for line in headers.lines() {
4733                            if let Some(value) = line.strip_prefix("Content-Length:") {
4734                                content_length = value.trim().parse::<usize>().unwrap_or(0);
4735                            }
4736                        }
4737                    }
4738                }
4739                if let Some(end) = header_end {
4740                    if buf.len() >= end + content_length {
4741                        break;
4742                    }
4743                }
4744            }
4745
4746            let end = header_end.expect("header terminator");
4747            let request = String::from_utf8_lossy(&buf[..end]).to_string();
4748            let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
4749            let mut lines = request.lines();
4750            let request_line = lines.next().expect("request line").to_string();
4751            let path = request_line
4752                .split_whitespace()
4753                .nth(1)
4754                .expect("request path")
4755                .to_string();
4756            let response_body = handler(request_line, path, body);
4757            let response = format!(
4758                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4759                response_body.len(),
4760                response_body
4761            );
4762            stream
4763                .write_all(response.as_bytes())
4764                .expect("write response");
4765        });
4766
4767        (format!("http://{}", addr), handle)
4768    }
4769
4770    fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
4771        let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
4772        listener
4773            .set_nonblocking(true)
4774            .expect("nonblocking listener");
4775        let addr = listener.local_addr().expect("local addr");
4776        let handle = thread::spawn(move || {
4777            // The deadline is only a hang-backstop for the case where the client
4778            // makes FEWER than `attempts` connections. It MUST comfortably exceed
4779            // the client's full retry budget (3 attempts: 3x250ms read-timeouts +
4780            // 500ms + 1000ms backoffs ~= 2.25s) so the last connect is always
4781            // accepted — otherwise the 3rd connect lands after a too-short
4782            // deadline, the server thread is already gone, and the client gets a
4783            // connect error ("request failed") instead of the body-read error the
4784            // test asserts. Under loaded CI (esp. Windows) thread scheduling
4785            // drifts the connects later, so this needs generous headroom.
4786            let deadline = std::time::Instant::now() + Duration::from_secs(30);
4787            let mut accepted = 0usize;
4788            while accepted < attempts && std::time::Instant::now() < deadline {
4789                match listener.accept() {
4790                    Ok((mut stream, _)) => {
4791                        accepted += 1;
4792                        let mut buf = [0u8; 4096];
4793                        // The client (under test) uses a 250ms timeout and drops
4794                        // the connection when the truncated body never completes.
4795                        // On Windows that disconnect surfaces as a hard socket
4796                        // error (WSAECONNRESET) on these read/write calls, where
4797                        // Unix returns a clean EOF. Tolerate both: the mock does
4798                        // not need the request bytes, and a write to an
4799                        // already-hung-up client is expected.
4800                        let _ = stream.read(&mut buf);
4801                        let response = "HTTP/1.1 200 OK
4802Content-Type: application/json
4803Content-Length: 128
4804Connection: close
4805
4806{";
4807                        let _ = stream.write_all(response.as_bytes());
4808                    }
4809                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
4810                        thread::sleep(Duration::from_millis(10));
4811                    }
4812                    Err(error) => panic!("accept request: {error}"),
4813                }
4814            }
4815        });
4816
4817        (format!("http://{}", addr), handle)
4818    }
4819
4820    #[test]
4821    fn response_body_read_failures_are_marked_transient() {
4822        let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
4823        // Generous client timeout: this test classifies BODY-TRUNCATION errors,
4824        // and a tight budget flips the failure into a connect/send timeout on a
4825        // loaded machine, changing which error string the assertions see.
4826        let client = Client::builder()
4827            .timeout(Duration::from_secs(5))
4828            .build()
4829            .expect("client");
4830
4831        let error = send_embedding_request(
4832            || client.post(&url).body("{}"),
4833            "test backend",
4834            EmbeddingRequestPolicy::Build,
4835        )
4836        .expect_err("truncated body should fail");
4837
4838        handle.join().unwrap();
4839        assert!(
4840            embedding_failure_is_transient(&error),
4841            "body read failures should be transient-marked: {error}"
4842        );
4843        // The mock closes the socket after writing a truncated body. Whether
4844        // the client observes that as a body-read EOF or as a send-stage
4845        // connection reset is an OS-level race (Windows sends RST when the
4846        // socket closes with unread request bytes, and under load the mock's
4847        // single read can return early). Both shapes are the backend dying
4848        // mid-exchange and both must carry the transient marker; the message
4849        // prefix differs by stage.
4850        assert!(
4851            error.contains("response read failed") || error.contains("request failed"),
4852            "unexpected error shape: {error}"
4853        );
4854    }
4855
4856    fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
4857        Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
4858    }
4859
4860    fn write_rust_file(path: &Path, function_name: &str) {
4861        fs::write(
4862            path,
4863            format!("pub fn {function_name}() -> bool {{\n    true\n}}\n"),
4864        )
4865        .unwrap();
4866    }
4867
4868    fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
4869        let mut embed = test_vector_for_texts;
4870        SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
4871    }
4872
4873    fn test_project_root() -> PathBuf {
4874        std::env::current_dir().unwrap()
4875    }
4876
4877    #[test]
4878    fn empty_snapshot_replaces_nonempty_and_loads_as_valid_tombstone() {
4879        let project = tempfile::tempdir().expect("create project");
4880        let storage = tempfile::tempdir().expect("create storage");
4881        let source = project.path().join("lib.rs");
4882        write_rust_file(&source, "persisted_symbol");
4883        let populated = build_test_index(project.path(), std::slice::from_ref(&source));
4884        assert!(populated.write_to_disk(storage.path(), "project"));
4885
4886        let data_path = storage.path().join("semantic/project/semantic.bin");
4887        let populated_bytes = fs::read(&data_path).expect("read populated snapshot");
4888        let empty = SemanticIndex::new(project.path().to_path_buf(), populated.dimension());
4889        assert!(empty.write_to_disk(storage.path(), "project"));
4890        let empty_bytes = fs::read(&data_path).expect("read explicit empty snapshot");
4891        assert_ne!(empty_bytes, populated_bytes);
4892        let decoded = SemanticIndex::from_bytes(&empty_bytes, project.path())
4893            .expect("decode explicit empty snapshot");
4894        assert_eq!(decoded.entry_count(), 0);
4895        for _ in 0..2 {
4896            let loaded = SemanticIndex::read_from_disk(
4897                storage.path(),
4898                "project",
4899                project.path(),
4900                false,
4901                None,
4902            )
4903            .expect("explicit empty snapshot remains loadable");
4904            assert_eq!(loaded.entry_count(), 0);
4905        }
4906    }
4907
4908    #[test]
4909    fn persistence_failure_is_reported_to_caller() {
4910        let project = tempfile::tempdir().expect("create project");
4911        let storage_parent = tempfile::tempdir().expect("create storage parent");
4912        let storage_file = storage_parent.path().join("not-a-directory");
4913        fs::write(&storage_file, b"occupied").expect("create blocking file");
4914        let empty = SemanticIndex::new(project.path().to_path_buf(), 3);
4915
4916        assert!(!empty.write_to_disk(&storage_file, "project"));
4917    }
4918
4919    #[test]
4920    fn semantic_memory_estimate_is_zero_when_empty_and_scales_with_entries() {
4921        let root = test_project_root();
4922        let mut index = SemanticIndex::new(root.clone(), 3);
4923        assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
4924
4925        let entry = |name: &str| EmbeddingEntry {
4926            chunk: SemanticChunk {
4927                file: root.join(format!("{name}.rs")),
4928                name: name.to_string(),
4929                qualified_name: Some(format!("module::{name}")),
4930                kind: SymbolKind::Function,
4931                start_line: 0,
4932                end_line: 1,
4933                exported: true,
4934                embed_text: format!("function {name} body"),
4935                snippet: format!("fn {name}() {{}}"),
4936            },
4937            norm: vector_norm(&[1.0, 2.0, 3.0]),
4938            vector: vec![1.0, 2.0, 3.0],
4939        };
4940        index.entries.push(entry("one"));
4941        let one_entry = index.estimated_memory().estimated_bytes.unwrap();
4942        assert!(one_entry > 0);
4943        index.entries.push(entry("two"));
4944        let two_entries = index.estimated_memory().estimated_bytes.unwrap();
4945        assert!(two_entries > one_entry);
4946    }
4947
4948    fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
4949        index.file_mtimes.insert(file.to_path_buf(), mtime);
4950        index.file_sizes.insert(file.to_path_buf(), size);
4951        index
4952            .file_hashes
4953            .insert(file.to_path_buf(), cache_freshness::zero_hash());
4954    }
4955
4956    fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
4957        let mut buf = Vec::new();
4958        let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
4959            let encoded = fingerprint.as_string();
4960            if encoded.is_empty() {
4961                None
4962            } else {
4963                Some(encoded.into_bytes())
4964            }
4965        });
4966        let file_mtimes: Vec<_> = index
4967            .file_mtimes
4968            .iter()
4969            .filter_map(|(path, mtime)| {
4970                cache_relative_path(&index.project_root, path)
4971                    .map(|relative| (relative, path, mtime))
4972            })
4973            .collect();
4974        let entries: Vec<_> = index
4975            .entries
4976            .iter()
4977            .filter_map(|entry| {
4978                cache_relative_path(&index.project_root, &entry.chunk.file)
4979                    .map(|relative| (relative, entry))
4980            })
4981            .collect();
4982
4983        buf.push(SEMANTIC_INDEX_VERSION_V6);
4984        buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
4985        buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
4986        let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
4987        buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
4988        buf.extend_from_slice(fp_bytes_ref);
4989
4990        buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
4991        for (relative, path, mtime) in &file_mtimes {
4992            let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
4993            buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
4994            buf.extend_from_slice(&path_bytes);
4995            let duration = mtime
4996                .duration_since(SystemTime::UNIX_EPOCH)
4997                .unwrap_or_default();
4998            buf.extend_from_slice(&duration.as_secs().to_le_bytes());
4999            buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
5000            let size = index.file_sizes.get(*path).copied().unwrap_or_default();
5001            buf.extend_from_slice(&size.to_le_bytes());
5002            let hash = index
5003                .file_hashes
5004                .get(*path)
5005                .copied()
5006                .unwrap_or_else(cache_freshness::zero_hash);
5007            buf.extend_from_slice(hash.as_bytes());
5008        }
5009
5010        for (relative, entry) in &entries {
5011            let c = &entry.chunk;
5012            let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
5013            buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
5014            buf.extend_from_slice(&file_bytes);
5015
5016            let name_bytes = c.name.as_bytes();
5017            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
5018            buf.extend_from_slice(name_bytes);
5019
5020            buf.push(symbol_kind_to_u8(&c.kind));
5021            buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
5022            buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
5023            buf.push(c.exported as u8);
5024
5025            let snippet_bytes = c.snippet.as_bytes();
5026            buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
5027            buf.extend_from_slice(snippet_bytes);
5028
5029            let embed_bytes = c.embed_text.as_bytes();
5030            buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
5031            buf.extend_from_slice(embed_bytes);
5032
5033            for &val in &entry.vector {
5034                buf.extend_from_slice(&val.to_le_bytes());
5035            }
5036        }
5037
5038        buf
5039    }
5040
5041    #[derive(Default)]
5042    struct RecordingEmbedder {
5043        calls: Vec<Vec<String>>,
5044    }
5045
5046    impl RecordingEmbedder {
5047        fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
5048            let vectors = texts
5049                .iter()
5050                .map(|text| deterministic_test_vector(text))
5051                .collect();
5052            self.calls.push(texts);
5053            Ok(vectors)
5054        }
5055
5056        fn total_embedded_texts(&self) -> usize {
5057            self.calls.iter().map(Vec::len).sum()
5058        }
5059
5060        fn embedded_texts(&self) -> Vec<&str> {
5061            self.calls
5062                .iter()
5063                .flat_map(|batch| batch.iter().map(String::as_str))
5064                .collect()
5065        }
5066    }
5067
5068    fn deterministic_test_vector(text: &str) -> Vec<f32> {
5069        let hash = blake3::hash(text.as_bytes());
5070        let bytes = hash.as_bytes();
5071        vec![
5072            1.0,
5073            bytes[0] as f32 / 255.0,
5074            bytes[1] as f32 / 255.0,
5075            bytes[2] as f32 / 255.0,
5076        ]
5077    }
5078
5079    fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
5080        let mut embedder = RecordingEmbedder::default();
5081        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5082        SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
5083    }
5084
5085    fn force_stale(index: &mut SemanticIndex, file: &Path) {
5086        set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
5087    }
5088
5089    fn write_source(path: &Path, source: &str) {
5090        if let Some(parent) = path.parent() {
5091            fs::create_dir_all(parent).unwrap();
5092        }
5093        fs::write(path, source).unwrap();
5094    }
5095
5096    fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
5097        index
5098            .entries
5099            .iter()
5100            .filter(|entry| entry.chunk.file == file)
5101            .collect()
5102    }
5103
5104    fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
5105        index
5106            .entries
5107            .iter()
5108            .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
5109            .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
5110    }
5111
5112    fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
5113        index
5114            .entries
5115            .iter()
5116            .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
5117            .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
5118    }
5119
5120    #[test]
5121    fn borrowed_snapshots_deserialize_once_share_memory_and_drop_with_last_holder() {
5122        let owner = tempfile::tempdir().unwrap();
5123        let storage = tempfile::tempdir().unwrap();
5124        let borrower_a = tempfile::tempdir().unwrap();
5125        let borrower_b = tempfile::tempdir().unwrap();
5126        let relative = Path::new("src/lib.rs");
5127        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
5128            let file = root.join(relative);
5129            fs::create_dir_all(file.parent().unwrap()).unwrap();
5130            fs::write(&file, "pub fn shared_symbol() -> bool { true }\n").unwrap();
5131        }
5132        let owner_file = owner.path().join(relative);
5133        let metadata = fs::metadata(&owner_file).unwrap();
5134        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 3);
5135        index.entries.push(EmbeddingEntry {
5136            chunk: SemanticChunk {
5137                file: owner_file.clone(),
5138                name: "shared_symbol".to_string(),
5139                qualified_name: None,
5140                kind: SymbolKind::Function,
5141                start_line: 0,
5142                end_line: 0,
5143                exported: true,
5144                embed_text: "shared symbol".to_string(),
5145                snippet: "pub fn shared_symbol() -> bool { true }".to_string(),
5146            },
5147            norm: vector_norm(&[1.0, 0.0, 0.0]),
5148            vector: vec![1.0, 0.0, 0.0],
5149        });
5150        index.file_mtimes.insert(
5151            owner_file.clone(),
5152            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
5153        );
5154        index.file_sizes.insert(owner_file.clone(), metadata.len());
5155        index.file_hashes.insert(
5156            owner_file,
5157            blake3::hash(b"pub fn shared_symbol() -> bool { true }\n"),
5158        );
5159        index.set_fingerprint(SemanticIndexFingerprint {
5160            backend: "test".to_string(),
5161            model: "shared-base".to_string(),
5162            base_url: FALLBACK_BACKEND.to_string(),
5163            dimension: 3,
5164            chunking_version: default_chunking_version(),
5165        });
5166        assert!(index.shared_base.is_none(), "owner indexes stay private");
5167
5168        let project_key = format!(
5169            "shared-base-{}",
5170            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
5171        );
5172        let dir = storage.path().join("semantic").join(&project_key);
5173        fs::create_dir_all(&dir).unwrap();
5174        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5175        let loads_before = SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed);
5176        let hits_before = SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed);
5177        let a = SemanticIndex::read_from_disk_borrow_tolerant(
5178            storage.path(),
5179            &project_key,
5180            borrower_a.path(),
5181        )
5182        .unwrap();
5183        let b = SemanticIndex::read_from_disk_borrow_tolerant(
5184            storage.path(),
5185            &project_key,
5186            borrower_b.path(),
5187        )
5188        .unwrap();
5189        let a_base = a.shared_base.as_ref().unwrap();
5190        let b_base = b.shared_base.as_ref().unwrap();
5191        assert!(Arc::ptr_eq(a_base, b_base));
5192        assert!(SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) > loads_before);
5193        assert!(SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) > hits_before);
5194        assert_eq!(
5195            a.search(&[1.0, 0.0, 0.0], 1)[0].file,
5196            borrower_a.path().join(relative)
5197        );
5198        assert_eq!(
5199            b.search(&[1.0, 0.0, 0.0], 1)[0].file,
5200            borrower_b.path().join(relative)
5201        );
5202        assert_eq!(a.estimated_memory().estimated_bytes, Some(0));
5203        assert!(shared_semantic_bases_memory().estimated_bytes.unwrap_or(0) > 0);
5204
5205        let weak = Arc::downgrade(a_base);
5206        let ctx = crate::context::AppContext::new(
5207            Box::new(crate::parser::TreeSitterProvider::new()),
5208            crate::config::Config {
5209                project_root: Some(borrower_a.path().to_path_buf()),
5210                ..crate::config::Config::default()
5211            },
5212        );
5213        *ctx.semantic_index()
5214            .write()
5215            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(a);
5216        assert!(ctx.evict_idle_artifacts());
5217        assert!(
5218            weak.upgrade().is_some(),
5219            "the second borrower keeps the base live"
5220        );
5221        drop(b);
5222        assert!(
5223            weak.upgrade().is_none(),
5224            "the last borrower releases the base"
5225        );
5226    }
5227
5228    #[test]
5229    fn borrowed_snapshot_hash_change_falls_back_to_private_copy() {
5230        let owner = tempfile::tempdir().unwrap();
5231        let storage = tempfile::tempdir().unwrap();
5232        let borrower_a = tempfile::tempdir().unwrap();
5233        let borrower_b = tempfile::tempdir().unwrap();
5234        let relative = Path::new("src/lib.rs");
5235        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
5236            let file = root.join(relative);
5237            fs::create_dir_all(file.parent().unwrap()).unwrap();
5238            fs::write(&file, "pub fn hash_guard() {}\n").unwrap();
5239        }
5240        let owner_file = owner.path().join(relative);
5241        let metadata = fs::metadata(&owner_file).unwrap();
5242        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 2);
5243        index.entries.push(EmbeddingEntry {
5244            chunk: SemanticChunk {
5245                file: owner_file.clone(),
5246                name: "hash_guard".to_string(),
5247                qualified_name: None,
5248                kind: SymbolKind::Function,
5249                start_line: 0,
5250                end_line: 0,
5251                exported: true,
5252                embed_text: "hash guard".to_string(),
5253                snippet: "pub fn hash_guard() {}".to_string(),
5254            },
5255            norm: vector_norm(&[1.0, 0.0]),
5256            vector: vec![1.0, 0.0],
5257        });
5258        index.file_mtimes.insert(
5259            owner_file.clone(),
5260            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
5261        );
5262        index.file_sizes.insert(owner_file.clone(), metadata.len());
5263        index
5264            .file_hashes
5265            .insert(owner_file, blake3::hash(b"pub fn hash_guard() {}\n"));
5266        index.set_fingerprint(SemanticIndexFingerprint {
5267            backend: "test".to_string(),
5268            model: "hash-guard".to_string(),
5269            base_url: FALLBACK_BACKEND.to_string(),
5270            dimension: 2,
5271            chunking_version: default_chunking_version(),
5272        });
5273        let project_key = format!(
5274            "hash-fallback-{}",
5275            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
5276        );
5277        let dir = storage.path().join("semantic").join(&project_key);
5278        fs::create_dir_all(&dir).unwrap();
5279        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5280        let shared = SemanticIndex::read_from_disk_borrow_tolerant(
5281            storage.path(),
5282            &project_key,
5283            borrower_a.path(),
5284        )
5285        .unwrap();
5286        assert!(shared.shared_base.is_some());
5287
5288        let changed_vector = vec![0.0, 1.0];
5289        index.entries[0].norm = vector_norm(&changed_vector);
5290        index.entries[0].vector = changed_vector;
5291        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
5292        let fallback = SemanticIndex::read_from_disk_borrow_tolerant(
5293            storage.path(),
5294            &project_key,
5295            borrower_b.path(),
5296        )
5297        .unwrap();
5298        assert!(
5299            fallback.shared_base.is_none(),
5300            "a different byte identity must not join the live shared generation"
5301        );
5302        drop(shared);
5303    }
5304
5305    #[test]
5306    fn borrow_only_root_skips_semantic_lock_and_persist() {
5307        let project = tempfile::tempdir().expect("project");
5308        let source = project.path().join("lib.rs");
5309        write_rust_file(&source, "borrow_only_symbol");
5310        let project_key = "shared-artifact-key".to_string();
5311        let storage = tempfile::tempdir().expect("storage");
5312        crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
5313
5314        let _lock = SemanticIndexLock::acquire(storage.path(), &project_key, project.path())
5315            .expect("borrow-only lock downgrade");
5316        let cache_dir = storage.path().join("semantic").join(&project_key);
5317        assert!(!cache_dir.join("cache.lock").exists());
5318
5319        let index = build_test_index(project.path(), &[source]);
5320        index.write_to_disk(storage.path(), &project_key);
5321
5322        assert!(!cache_dir.join("semantic.bin").exists());
5323        assert!(!cache_dir.exists());
5324    }
5325
5326    #[test]
5327    fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
5328        let temp = tempfile::tempdir().unwrap();
5329        let project_root = temp.path();
5330        let file = project_root.join("src/lib.rs");
5331        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
5332        write_source(&file, original);
5333
5334        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5335        let original_entry_count = index.entries.len();
5336        let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
5337
5338        write_source(&file, &format!("\n{original}"));
5339        force_stale(&mut index, &file);
5340
5341        let mut embedder = RecordingEmbedder::default();
5342        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5343        let mut progress = |_done: usize, _total: usize| {};
5344        let summary = index
5345            .refresh_stale_files(
5346                project_root,
5347                std::slice::from_ref(&file),
5348                &mut embed,
5349                16,
5350                &mut progress,
5351            )
5352            .unwrap();
5353
5354        assert_eq!(summary.changed, 1);
5355        assert_eq!(embedder.total_embedded_texts(), 0);
5356        assert_eq!(index.entries.len(), original_entry_count);
5357        let shifted_alpha = entry_by_name(&index, &file, "alpha");
5358        assert_eq!(shifted_alpha.chunk.start_line, 1);
5359        assert_eq!(shifted_alpha.vector, original_alpha_vector);
5360    }
5361
5362    #[test]
5363    fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
5364        let temp = tempfile::tempdir().unwrap();
5365        let project_root = temp.path();
5366        let file = project_root.join("src/lib.rs");
5367        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
5368        write_source(&file, original);
5369
5370        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5371        let mut serving_index = worker_index.clone();
5372        let original_entry_count = worker_index.entries.len();
5373
5374        write_source(&file, &format!("\n{original}"));
5375
5376        let mut embedder = RecordingEmbedder::default();
5377        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5378        let mut progress = |_done: usize, _total: usize| {};
5379        let update = worker_index
5380            .refresh_invalidated_files(
5381                project_root,
5382                std::slice::from_ref(&file),
5383                &mut embed,
5384                16,
5385                100,
5386                &mut progress,
5387            )
5388            .unwrap();
5389
5390        assert_eq!(embedder.total_embedded_texts(), 0);
5391        assert_eq!(update.added_entries.len(), original_entry_count);
5392        assert_eq!(worker_index.entries.len(), original_entry_count);
5393
5394        serving_index.apply_refresh_update(
5395            update.added_entries,
5396            update.updated_metadata,
5397            &update.completed_paths,
5398        );
5399
5400        assert_eq!(serving_index.entries.len(), original_entry_count);
5401        assert_eq!(
5402            entries_for_file(&serving_index, &file).len(),
5403            original_entry_count
5404        );
5405        assert_eq!(
5406            entry_by_name(&serving_index, &file, "alpha")
5407                .chunk
5408                .start_line,
5409            1
5410        );
5411    }
5412
5413    #[test]
5414    fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
5415        let temp = tempfile::tempdir().unwrap();
5416        let project_root = temp.path();
5417        let file = project_root.join("src/lib.rs");
5418        write_source(
5419            &file,
5420            "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
5421        );
5422
5423        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5424        let original_entry_count = index.entries.len();
5425        let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
5426
5427        write_source(
5428            &file,
5429            "pub fn alpha() -> i32 {\n    10\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
5430        );
5431
5432        let mut embedder = RecordingEmbedder::default();
5433        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5434        let mut progress = |_done: usize, _total: usize| {};
5435        let update = index
5436            .refresh_invalidated_files(
5437                project_root,
5438                std::slice::from_ref(&file),
5439                &mut embed,
5440                16,
5441                100,
5442                &mut progress,
5443            )
5444            .unwrap();
5445
5446        assert_eq!(embedder.total_embedded_texts(), 1);
5447        assert!(embedder.embedded_texts()[0].contains("name:alpha"));
5448        assert_eq!(update.added_entries.len(), original_entry_count);
5449        assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
5450    }
5451
5452    #[test]
5453    fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
5454        let temp = tempfile::tempdir().unwrap();
5455        let project_root = temp.path();
5456        let file = project_root.join("src/dupe.js");
5457        let one_duplicate = "function duplicate() {\n  return 1;\n}\n";
5458        write_source(&file, one_duplicate);
5459
5460        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5461        let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
5462
5463        write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
5464
5465        let mut embedder = RecordingEmbedder::default();
5466        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5467        let mut progress = |_done: usize, _total: usize| {};
5468        index
5469            .refresh_invalidated_files(
5470                project_root,
5471                std::slice::from_ref(&file),
5472                &mut embed,
5473                16,
5474                100,
5475                &mut progress,
5476            )
5477            .unwrap();
5478
5479        let duplicate_entries = index
5480            .entries
5481            .iter()
5482            .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
5483            .collect::<Vec<_>>();
5484        assert_eq!(duplicate_entries.len(), 2);
5485        assert_eq!(embedder.total_embedded_texts(), 0);
5486        assert_eq!(duplicate_entries[0].vector, original_vector);
5487        assert_eq!(duplicate_entries[1].vector, original_vector);
5488    }
5489
5490    #[test]
5491    fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
5492        let temp = tempfile::tempdir().unwrap();
5493        let project_root = temp.path();
5494        let file = project_root.join("src/lib.rs");
5495        write_source(
5496            &file,
5497            "//! module docs v1\n\npub fn alpha() -> i32 {\n    1\n}\n",
5498        );
5499
5500        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5501        let summary_before = file_summary_entry(&index, &file).vector.clone();
5502
5503        write_source(
5504            &file,
5505            "//! module docs v1\n\npub fn alpha() -> i32 {\n    2\n}\n",
5506        );
5507        let mut body_embedder = RecordingEmbedder::default();
5508        let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
5509        let mut progress = |_done: usize, _total: usize| {};
5510        index
5511            .refresh_invalidated_files(
5512                project_root,
5513                std::slice::from_ref(&file),
5514                &mut body_embed,
5515                16,
5516                100,
5517                &mut progress,
5518            )
5519            .unwrap();
5520        assert_eq!(body_embedder.total_embedded_texts(), 1);
5521        assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
5522        assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
5523
5524        write_source(
5525            &file,
5526            "//! module docs v2\n\npub fn alpha() -> i32 {\n    2\n}\n",
5527        );
5528        let mut doc_embedder = RecordingEmbedder::default();
5529        let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
5530        index
5531            .refresh_invalidated_files(
5532                project_root,
5533                std::slice::from_ref(&file),
5534                &mut doc_embed,
5535                16,
5536                100,
5537                &mut progress,
5538            )
5539            .unwrap();
5540
5541        assert_eq!(doc_embedder.total_embedded_texts(), 1);
5542        assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
5543        assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
5544    }
5545
5546    #[test]
5547    fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
5548        let temp = tempfile::tempdir().unwrap();
5549        let project_root = temp.path();
5550        let file = project_root.join("src/lib.rs");
5551        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
5552
5553        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5554        let mut serving_index = worker_index.clone();
5555        fs::remove_file(&file).unwrap();
5556
5557        let mut embedder = RecordingEmbedder::default();
5558        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5559        let mut progress = |_done: usize, _total: usize| {};
5560        let update = worker_index
5561            .refresh_invalidated_files(
5562                project_root,
5563                std::slice::from_ref(&file),
5564                &mut embed,
5565                16,
5566                100,
5567                &mut progress,
5568            )
5569            .unwrap();
5570
5571        assert_eq!(update.summary.deleted, 1);
5572        assert_eq!(embedder.total_embedded_texts(), 0);
5573        assert!(worker_index.entries.is_empty());
5574
5575        serving_index.apply_refresh_update(
5576            update.added_entries,
5577            update.updated_metadata,
5578            &update.completed_paths,
5579        );
5580        assert!(serving_index.entries.is_empty());
5581    }
5582
5583    #[test]
5584    fn watcher_collect_failure_does_not_resurrect_stale_entries() {
5585        let temp = tempfile::tempdir().unwrap();
5586        let project_root = temp.path();
5587        let file = project_root.join("src/lib.rs");
5588        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
5589
5590        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
5591        let mut serving_index = worker_index.clone();
5592        fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
5593
5594        let mut embedder = RecordingEmbedder::default();
5595        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5596        let mut progress = |_done: usize, _total: usize| {};
5597        let update = worker_index
5598            .refresh_invalidated_files(
5599                project_root,
5600                std::slice::from_ref(&file),
5601                &mut embed,
5602                16,
5603                100,
5604                &mut progress,
5605            )
5606            .unwrap();
5607
5608        assert_eq!(embedder.total_embedded_texts(), 0);
5609        assert!(update.added_entries.is_empty());
5610        assert!(worker_index.entries.is_empty());
5611        assert!(!worker_index.file_mtimes.contains_key(&file));
5612
5613        serving_index.apply_refresh_update(
5614            update.added_entries,
5615            update.updated_metadata,
5616            &update.completed_paths,
5617        );
5618        assert!(serving_index.entries.is_empty());
5619        assert!(!serving_index.file_mtimes.contains_key(&file));
5620    }
5621
5622    #[test]
5623    fn refresh_invalidated_cap_deferral_remains_file_count_based() {
5624        let temp = tempfile::tempdir().unwrap();
5625        let project_root = temp.path();
5626        let indexed = project_root.join("src/a.rs");
5627        let deferred = project_root.join("src/b.rs");
5628        write_source(&indexed, "pub fn alpha() -> i32 {\n    1\n}\n");
5629        write_source(&deferred, "pub fn beta() -> i32 {\n    2\n}\n");
5630
5631        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
5632        let mut embedder = RecordingEmbedder::default();
5633        let mut embed = |texts: Vec<String>| embedder.embed(texts);
5634        let mut progress = |_done: usize, _total: usize| {};
5635        let update = index
5636            .refresh_invalidated_files(
5637                project_root,
5638                std::slice::from_ref(&deferred),
5639                &mut embed,
5640                16,
5641                1,
5642                &mut progress,
5643            )
5644            .unwrap();
5645
5646        assert_eq!(update.summary.total_processed, 1);
5647        assert_eq!(update.summary.added, 0);
5648        assert_eq!(embedder.total_embedded_texts(), 0);
5649        assert_eq!(index.indexed_file_count(), 1);
5650        assert!(index.deferred_files.contains(&deferred));
5651        assert!(entries_for_file(&index, &deferred).is_empty());
5652    }
5653
5654    #[test]
5655    fn semantic_cache_serialization_skips_paths_outside_project_root() {
5656        let dir = tempfile::tempdir().expect("create temp dir");
5657        let project = fs::canonicalize(dir.path()).expect("canonical project");
5658        let outside = project.join("..").join("outside.rs");
5659        let mut index = SemanticIndex::new(project.clone(), 3);
5660        index
5661            .file_mtimes
5662            .insert(outside.clone(), SystemTime::UNIX_EPOCH);
5663        index.file_sizes.insert(outside.clone(), 1);
5664        index
5665            .file_hashes
5666            .insert(outside.clone(), cache_freshness::zero_hash());
5667        index.entries.push(EmbeddingEntry {
5668            chunk: SemanticChunk {
5669                file: outside,
5670                name: "outside".to_string(),
5671                qualified_name: None,
5672                kind: SymbolKind::Function,
5673                start_line: 0,
5674                end_line: 0,
5675                exported: false,
5676                embed_text: "outside".to_string(),
5677                snippet: "outside".to_string(),
5678            },
5679            norm: vector_norm(&[1.0, 0.0, 0.0]),
5680            vector: vec![1.0, 0.0, 0.0],
5681        });
5682
5683        let bytes = index.to_bytes();
5684        let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
5685        assert_eq!(loaded.entries.len(), 0);
5686        assert!(loaded.file_mtimes.is_empty());
5687    }
5688
5689    #[test]
5690    fn semantic_search_bounded_top_k_matches_reference_full_sort() {
5691        let project_root = test_project_root();
5692        let file = project_root.join("src/lib.rs");
5693        let mut index = SemanticIndex::new(project_root, 2);
5694        let entries = [
5695            ("alpha", vec![2.0, 0.0], false),
5696            ("beta", vec![0.0, 3.0], false),
5697            ("gamma", vec![4.0, 0.0], false),
5698            ("delta", vec![1.0, 1.0], true),
5699            ("epsilon", vec![-5.0, 0.0], false),
5700        ];
5701        for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
5702            index.entries.push(EmbeddingEntry {
5703                chunk: SemanticChunk {
5704                    file: file.clone(),
5705                    name: name.to_string(),
5706                    qualified_name: None,
5707                    kind: SymbolKind::Function,
5708                    start_line: line as u32 + 1,
5709                    end_line: line as u32 + 1,
5710                    exported,
5711                    embed_text: name.to_string(),
5712                    snippet: format!("fn {name}() {{}}"),
5713                },
5714                norm: vector_norm(&vector),
5715                vector,
5716            });
5717        }
5718
5719        let query = vec![2.0, 0.0];
5720        let top_k = 4;
5721        let mut reference: Vec<(f32, usize)> = index
5722            .entries
5723            .iter()
5724            .enumerate()
5725            .map(|(idx, entry)| {
5726                // Recompute both norms for every entry as the reference
5727                // implementation, so cached norms cannot change ranking or scores.
5728                let mut dot = 0.0f32;
5729                let mut query_squared_norm = 0.0f32;
5730                let mut entry_squared_norm = 0.0f32;
5731                for i in 0..query.len() {
5732                    dot += query[i] * entry.vector[i];
5733                    query_squared_norm += query[i] * query[i];
5734                    entry_squared_norm += entry.vector[i] * entry.vector[i];
5735                }
5736                let denom = query_squared_norm.sqrt() * entry_squared_norm.sqrt();
5737                let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
5738                if entry.chunk.exported {
5739                    score *= 1.1;
5740                }
5741                (score, idx)
5742            })
5743            .collect();
5744        reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
5745        let expected: Vec<(String, f32)> = reference
5746            .into_iter()
5747            .take(top_k)
5748            .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
5749            .collect();
5750
5751        let actual: Vec<(String, f32)> = index
5752            .search(&query, top_k)
5753            .into_iter()
5754            .map(|result| (result.name, result.score))
5755            .collect();
5756
5757        assert_eq!(
5758            actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
5759            expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
5760        );
5761        for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
5762            assert!((actual_score - expected_score).abs() < 1e-6);
5763        }
5764        assert_eq!(actual[0].0, "alpha");
5765        assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
5766        assert!(index.search(&query, 0).is_empty());
5767    }
5768
5769    #[test]
5770    fn test_cosine_similarity_identical() {
5771        let a = vec![1.0, 0.0, 0.0];
5772        let b = vec![1.0, 0.0, 0.0];
5773        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
5774    }
5775
5776    #[test]
5777    fn test_cosine_similarity_orthogonal() {
5778        let a = vec![1.0, 0.0, 0.0];
5779        let b = vec![0.0, 1.0, 0.0];
5780        assert!(cosine_similarity(&a, &b).abs() < 0.001);
5781    }
5782
5783    #[test]
5784    fn test_cosine_similarity_opposite() {
5785        let a = vec![1.0, 0.0, 0.0];
5786        let b = vec![-1.0, 0.0, 0.0];
5787        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
5788    }
5789
5790    #[test]
5791    fn test_serialization_roundtrip() {
5792        let project_root = test_project_root();
5793        let file = project_root.join("src/main.rs");
5794        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
5795        index.entries.push(EmbeddingEntry {
5796            chunk: SemanticChunk {
5797                file: file.clone(),
5798                name: "handle_request".to_string(),
5799                qualified_name: None,
5800                kind: SymbolKind::Function,
5801                start_line: 10,
5802                end_line: 25,
5803                exported: true,
5804                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
5805                snippet: "fn handle_request() {\n  // ...\n}".to_string(),
5806            },
5807            norm: vector_norm(&[0.1, 0.2, 0.3, 0.4]),
5808            vector: vec![0.1, 0.2, 0.3, 0.4],
5809        });
5810        index.dimension = 4;
5811        index
5812            .file_mtimes
5813            .insert(file.clone(), SystemTime::UNIX_EPOCH);
5814        index.file_sizes.insert(file, 0);
5815        index.set_fingerprint(SemanticIndexFingerprint {
5816            backend: "fastembed".to_string(),
5817            model: "all-MiniLM-L6-v2".to_string(),
5818            base_url: FALLBACK_BACKEND.to_string(),
5819            dimension: 4,
5820            chunking_version: default_chunking_version(),
5821        });
5822
5823        let bytes = index.to_bytes();
5824        let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
5825
5826        assert_eq!(restored.entries.len(), 1);
5827        assert_eq!(restored.entries[0].chunk.name, "handle_request");
5828        assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
5829        assert_eq!(
5830            restored.entries[0].norm,
5831            vector_norm(&restored.entries[0].vector)
5832        );
5833        assert_eq!(restored.dimension, 4);
5834        assert_eq!(restored.backend_label(), Some("fastembed"));
5835        assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
5836    }
5837
5838    #[test]
5839    fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
5840        let storage = tempfile::tempdir().expect("create storage dir");
5841        let project = storage.path().join("project");
5842        fs::create_dir_all(project.join("src")).expect("create project src");
5843        let file = project.join("src/lib.rs");
5844        fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
5845        let project_root = fs::canonicalize(&project).expect("canonical project");
5846        let file = fs::canonicalize(&file).expect("canonical file");
5847
5848        let mut index = SemanticIndex::new(project_root.clone(), 3);
5849        let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
5850        index.file_mtimes.insert(file.clone(), mtime);
5851        index.file_sizes.insert(file.clone(), 42);
5852        index
5853            .file_hashes
5854            .insert(file.clone(), cache_freshness::zero_hash());
5855        index.entries.push(EmbeddingEntry {
5856            chunk: SemanticChunk {
5857                file: file.clone(),
5858                name: "alpha".to_string(),
5859                qualified_name: Some("Service.alpha".to_string()),
5860                kind: SymbolKind::Function,
5861                start_line: 0,
5862                end_line: 0,
5863                exported: true,
5864                embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
5865                snippet: "pub fn alpha() {}".to_string(),
5866            },
5867            norm: vector_norm(&[0.1, 0.2, 0.3]),
5868            vector: vec![0.1, 0.2, 0.3],
5869        });
5870        index.entries.push(EmbeddingEntry {
5871            chunk: SemanticChunk {
5872                file: file.clone(),
5873                name: "beta".to_string(),
5874                qualified_name: Some("Service.beta".to_string()),
5875                kind: SymbolKind::Function,
5876                start_line: 1,
5877                end_line: 1,
5878                exported: true,
5879                embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
5880                snippet: "pub fn beta() {}".to_string(),
5881            },
5882            norm: vector_norm(&[0.4, 0.5, 0.6]),
5883            vector: vec![0.4, 0.5, 0.6],
5884        });
5885        let fingerprint = SemanticIndexFingerprint {
5886            backend: "fastembed".to_string(),
5887            model: "all-MiniLM-L6-v2".to_string(),
5888            base_url: FALLBACK_BACKEND.to_string(),
5889            dimension: 3,
5890            chunking_version: default_chunking_version(),
5891        };
5892        let fingerprint_before = fingerprint.as_string();
5893        index.set_fingerprint(fingerprint.clone());
5894
5895        let legacy_bytes = legacy_semantic_index_bytes(&index);
5896        assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
5897        let legacy_dir = storage.path().join("semantic/legacy-proj");
5898        fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
5899        let legacy_path = legacy_dir.join("semantic.bin");
5900        fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
5901        let legacy_loaded = SemanticIndex::read_from_disk(
5902            storage.path(),
5903            "legacy-proj",
5904            &project_root,
5905            false,
5906            Some(&fingerprint_before),
5907        )
5908        .expect("load v6 semantic index");
5909        assert!(
5910            legacy_path.exists(),
5911            "compatible V6 cache must not be deleted"
5912        );
5913        assert!(legacy_loaded
5914            .entries
5915            .iter()
5916            .all(|entry| entry.chunk.qualified_name.is_none()));
5917        assert_eq!(
5918            legacy_loaded.fingerprint().unwrap().as_string(),
5919            fingerprint_before
5920        );
5921
5922        let v7_bytes = index.to_bytes();
5923        assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
5924        assert_ne!(v7_bytes, legacy_bytes);
5925        let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
5926        assert_eq!(
5927            restored.entries[0].chunk.qualified_name.as_deref(),
5928            Some("Service.alpha")
5929        );
5930        assert_eq!(
5931            restored.entries[1].chunk.qualified_name.as_deref(),
5932            Some("Service.beta")
5933        );
5934        assert_eq!(
5935            restored.fingerprint().unwrap().as_string(),
5936            fingerprint_before
5937        );
5938
5939        index.write_to_disk(storage.path(), "proj");
5940        let data_path = storage.path().join("semantic/proj/semantic.bin");
5941        let persisted = fs::read(&data_path).expect("read semantic.bin");
5942        assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
5943
5944        let loaded = SemanticIndex::read_from_disk(
5945            storage.path(),
5946            "proj",
5947            &project_root,
5948            false,
5949            Some(&fingerprint_before),
5950        )
5951        .expect("load semantic index");
5952        assert_eq!(loaded.entries.len(), index.entries.len());
5953        assert_eq!(loaded.dimension, index.dimension);
5954        assert_eq!(
5955            loaded.fingerprint().unwrap().as_string(),
5956            fingerprint_before
5957        );
5958        assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
5959        assert_eq!(loaded.file_sizes.get(&file), Some(&42));
5960        assert_eq!(
5961            loaded.file_hashes.get(&file),
5962            Some(&cache_freshness::zero_hash())
5963        );
5964        for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
5965            assert_eq!(actual.chunk.file, expected.chunk.file);
5966            assert_eq!(actual.chunk.name, expected.chunk.name);
5967            assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
5968            assert_eq!(actual.chunk.kind, expected.chunk.kind);
5969            assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
5970            assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
5971            assert_eq!(actual.chunk.exported, expected.chunk.exported);
5972            assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
5973            assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
5974            assert_eq!(actual.vector, expected.vector);
5975        }
5976        assert_eq!(loaded.to_bytes(), persisted);
5977        assert_eq!(fingerprint.as_string(), fingerprint_before);
5978    }
5979
5980    #[test]
5981    fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
5982        let cases = [
5983            (SymbolKind::Function, 0),
5984            (SymbolKind::Class, 1),
5985            (SymbolKind::Method, 2),
5986            (SymbolKind::Struct, 3),
5987            (SymbolKind::Interface, 4),
5988            (SymbolKind::Enum, 5),
5989            (SymbolKind::TypeAlias, 6),
5990            (SymbolKind::Variable, 7),
5991            (SymbolKind::Heading, 8),
5992            (SymbolKind::FileSummary, 9),
5993        ];
5994
5995        for (kind, encoded) in cases {
5996            assert_eq!(symbol_kind_to_u8(&kind), encoded);
5997            assert_eq!(u8_to_symbol_kind(encoded), kind);
5998        }
5999    }
6000
6001    #[test]
6002    fn test_search_top_k() {
6003        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6004        index.dimension = 3;
6005
6006        // Add entries with known vectors
6007        for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
6008            let mut vec = vec![0.0f32; 3];
6009            vec[i] = 1.0; // orthogonal vectors
6010            index.entries.push(EmbeddingEntry {
6011                chunk: SemanticChunk {
6012                    file: PathBuf::from("/src/lib.rs"),
6013                    name: name.to_string(),
6014                    qualified_name: None,
6015                    kind: SymbolKind::Function,
6016                    start_line: (i * 10 + 1) as u32,
6017                    end_line: (i * 10 + 5) as u32,
6018                    exported: true,
6019                    embed_text: format!("kind:function name:{}", name),
6020                    snippet: format!("fn {}() {{}}", name),
6021                },
6022                norm: vector_norm(&vec),
6023                vector: vec,
6024            });
6025        }
6026
6027        // Query aligned with "auth" (index 0)
6028        let query = vec![0.9, 0.1, 0.0];
6029        let results = index.search(&query, 2);
6030
6031        assert_eq!(results.len(), 2);
6032        assert_eq!(results[0].name, "auth"); // highest score
6033        assert!(results[0].score > results[1].score);
6034    }
6035
6036    #[test]
6037    fn test_empty_index_search() {
6038        let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6039        let results = index.search(&[0.1, 0.2, 0.3], 10);
6040        assert!(results.is_empty());
6041    }
6042
6043    #[test]
6044    fn single_line_symbol_builds_non_empty_snippet() {
6045        let symbol = Symbol {
6046            name: "answer".to_string(),
6047            kind: SymbolKind::Variable,
6048            range: crate::symbols::Range {
6049                start_line: 0,
6050                start_col: 0,
6051                end_line: 0,
6052                end_col: 24,
6053            },
6054            signature: Some("const answer = 42".to_string()),
6055            scope_chain: Vec::new(),
6056            exported: true,
6057            parent: None,
6058        };
6059        let source = "export const answer = 42;\n";
6060
6061        let snippet = build_snippet(&symbol, source);
6062
6063        assert_eq!(snippet, "export const answer = 42;");
6064    }
6065
6066    #[test]
6067    fn optimized_file_chunk_collection_matches_file_parser_path() {
6068        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
6069        let file = project_root.join("src/semantic_index.rs");
6070        let source = std::fs::read_to_string(&file).unwrap();
6071
6072        let mut legacy_parser = FileParser::new();
6073        let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
6074        let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
6075
6076        let optimized_chunks = collect_file_chunks(&project_root, &file).unwrap();
6077
6078        assert_eq!(
6079            chunk_fingerprint(&optimized_chunks),
6080            chunk_fingerprint(&legacy_chunks)
6081        );
6082    }
6083
6084    #[test]
6085    fn collect_file_chunks_indexes_java_symbols() {
6086        let dir = tempfile::tempdir().unwrap();
6087        let file = dir.path().join("Greeter.java");
6088        std::fs::write(
6089            &file,
6090            r#"package example;
6091
6092public class Greeter {
6093    public String greet(String name) {
6094        return "Hello, " + name;
6095    }
6096}
6097"#,
6098        )
6099        .unwrap();
6100
6101        let chunks = collect_file_chunks(dir.path(), &file).unwrap();
6102
6103        assert!(
6104            !chunks.is_empty(),
6105            "Java file should produce semantic chunks"
6106        );
6107        assert!(
6108            chunks
6109                .iter()
6110                .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
6111            "Java class symbol should be chunked: {chunks:?}"
6112        );
6113        assert!(
6114            chunks
6115                .iter()
6116                .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
6117            "Java method symbol should be chunked: {chunks:?}"
6118        );
6119    }
6120
6121    fn chunk_fingerprint(
6122        chunks: &[SemanticChunk],
6123    ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
6124        chunks
6125            .iter()
6126            .map(|chunk| {
6127                (
6128                    chunk.name.clone(),
6129                    chunk.kind.clone(),
6130                    chunk.start_line,
6131                    chunk.end_line,
6132                    chunk.exported,
6133                    chunk.embed_text.clone(),
6134                    chunk.snippet.clone(),
6135                )
6136            })
6137            .collect()
6138    }
6139
6140    #[test]
6141    fn collect_file_chunks_skips_oversized_file() {
6142        let dir = tempfile::tempdir().unwrap();
6143        let big = dir.path().join("huge.ts");
6144        // Just over the cap: a valid TS file that would otherwise yield chunks.
6145        let filler = "export const x = 1;\n"
6146            .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
6147        std::fs::write(&big, &filler).unwrap();
6148        assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
6149
6150        // Oversized → tracked with zero chunks, NOT an error (so the caller keeps
6151        // the file in metadata and freshness skips re-reading it).
6152        let chunks = collect_file_chunks(dir.path(), &big).unwrap();
6153        assert!(chunks.is_empty(), "oversized file must yield no chunks");
6154
6155        // A small file of the same language still produces chunks.
6156        let small = dir.path().join("small.ts");
6157        std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
6158        let small_chunks = collect_file_chunks(dir.path(), &small).unwrap();
6159        assert!(!small_chunks.is_empty(), "small file should still chunk");
6160    }
6161
6162    #[test]
6163    fn rejects_oversized_dimension_during_deserialization() {
6164        let mut bytes = Vec::new();
6165        bytes.push(1u8);
6166        bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
6167        bytes.extend_from_slice(&0u32.to_le_bytes());
6168        bytes.extend_from_slice(&0u32.to_le_bytes());
6169
6170        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
6171    }
6172
6173    #[test]
6174    fn rejects_oversized_entry_count_during_deserialization() {
6175        let mut bytes = Vec::new();
6176        bytes.push(1u8);
6177        bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
6178        bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
6179        bytes.extend_from_slice(&0u32.to_le_bytes());
6180
6181        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
6182    }
6183
6184    #[test]
6185    fn invalidate_file_removes_entries_and_mtime() {
6186        let target = PathBuf::from("/src/main.rs");
6187        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6188        index.entries.push(EmbeddingEntry {
6189            chunk: SemanticChunk {
6190                file: target.clone(),
6191                name: "main".to_string(),
6192                qualified_name: None,
6193                kind: SymbolKind::Function,
6194                start_line: 0,
6195                end_line: 1,
6196                exported: false,
6197                embed_text: "main".to_string(),
6198                snippet: "fn main() {}".to_string(),
6199            },
6200            norm: vector_norm(&[1.0; DEFAULT_DIMENSION]),
6201            vector: vec![1.0; DEFAULT_DIMENSION],
6202        });
6203        index
6204            .file_mtimes
6205            .insert(target.clone(), SystemTime::UNIX_EPOCH);
6206        index.file_sizes.insert(target.clone(), 0);
6207
6208        index.invalidate_file(&target);
6209
6210        assert!(index.entries.is_empty());
6211        assert!(!index.file_mtimes.contains_key(&target));
6212        assert!(!index.file_sizes.contains_key(&target));
6213    }
6214
6215    #[test]
6216    fn refresh_missing_changed_file_is_purged_after_collect() {
6217        let temp = tempfile::tempdir().unwrap();
6218        let project_root = temp.path();
6219        let file = project_root.join("src/lib.rs");
6220        fs::create_dir_all(file.parent().unwrap()).unwrap();
6221        write_rust_file(&file, "vanished_symbol");
6222
6223        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6224        let original_size = *index.file_sizes.get(&file).unwrap();
6225        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
6226        fs::remove_file(&file).unwrap();
6227
6228        let mut embed = test_vector_for_texts;
6229        let mut progress = |_done: usize, _total: usize| {};
6230        let summary = index
6231            .refresh_stale_files(
6232                project_root,
6233                std::slice::from_ref(&file),
6234                &mut embed,
6235                8,
6236                &mut progress,
6237            )
6238            .unwrap();
6239
6240        assert_eq!(summary.changed, 0);
6241        assert_eq!(summary.added, 0);
6242        assert_eq!(summary.deleted, 1);
6243        assert!(index.entries.is_empty());
6244        assert!(!index.file_mtimes.contains_key(&file));
6245        assert!(!index.file_sizes.contains_key(&file));
6246        assert!(!index.file_hashes.contains_key(&file));
6247    }
6248
6249    #[test]
6250    fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
6251        let temp = tempfile::tempdir().unwrap();
6252        let project_root = temp.path();
6253        let file = project_root.join("src/lib.rs");
6254        fs::create_dir_all(file.parent().unwrap()).unwrap();
6255        write_rust_file(&file, "kept_symbol");
6256
6257        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6258        let original_entry_count = index.entries.len();
6259        let original_mtime = *index.file_mtimes.get(&file).unwrap();
6260        let original_size = *index.file_sizes.get(&file).unwrap();
6261
6262        let stale_mtime = SystemTime::UNIX_EPOCH;
6263        set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
6264        fs::remove_file(&file).unwrap();
6265        fs::create_dir(&file).unwrap();
6266
6267        let mut embed = test_vector_for_texts;
6268        let mut progress = |_done: usize, _total: usize| {};
6269        let summary = index
6270            .refresh_stale_files(
6271                project_root,
6272                std::slice::from_ref(&file),
6273                &mut embed,
6274                8,
6275                &mut progress,
6276            )
6277            .unwrap();
6278
6279        assert_eq!(summary.changed, 0);
6280        assert_eq!(summary.added, 0);
6281        assert_eq!(summary.deleted, 0);
6282        assert_eq!(index.entries.len(), original_entry_count);
6283        assert!(index
6284            .entries
6285            .iter()
6286            .any(|entry| entry.chunk.name == "kept_symbol"));
6287        assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
6288        assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
6289        assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
6290    }
6291
6292    #[test]
6293    fn refresh_never_indexed_file_error_does_not_record_mtime() {
6294        let temp = tempfile::tempdir().unwrap();
6295        let project_root = temp.path();
6296        let missing = project_root.join("src/missing.rs");
6297        fs::create_dir_all(missing.parent().unwrap()).unwrap();
6298
6299        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6300        let mut embed = test_vector_for_texts;
6301        let mut progress = |_done: usize, _total: usize| {};
6302        let summary = index
6303            .refresh_stale_files(
6304                project_root,
6305                std::slice::from_ref(&missing),
6306                &mut embed,
6307                8,
6308                &mut progress,
6309            )
6310            .unwrap();
6311
6312        assert_eq!(summary.added, 0);
6313        assert_eq!(summary.changed, 0);
6314        assert_eq!(summary.deleted, 0);
6315        assert!(!index.file_mtimes.contains_key(&missing));
6316        assert!(!index.file_sizes.contains_key(&missing));
6317        assert!(index.entries.is_empty());
6318    }
6319
6320    #[test]
6321    fn refresh_reports_added_for_new_files() {
6322        let temp = tempfile::tempdir().unwrap();
6323        let project_root = temp.path();
6324        let existing = project_root.join("src/lib.rs");
6325        let added = project_root.join("src/new.rs");
6326        fs::create_dir_all(existing.parent().unwrap()).unwrap();
6327        write_rust_file(&existing, "existing_symbol");
6328        write_rust_file(&added, "added_symbol");
6329
6330        let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
6331        let mut embed = test_vector_for_texts;
6332        let mut progress = |_done: usize, _total: usize| {};
6333        let summary = index
6334            .refresh_stale_files(
6335                project_root,
6336                &[existing.clone(), added.clone()],
6337                &mut embed,
6338                8,
6339                &mut progress,
6340            )
6341            .unwrap();
6342
6343        assert_eq!(summary.added, 1);
6344        assert_eq!(summary.changed, 0);
6345        assert_eq!(summary.deleted, 0);
6346        assert_eq!(summary.total_processed, 2);
6347        assert!(index.file_mtimes.contains_key(&added));
6348        assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
6349    }
6350
6351    #[test]
6352    fn refresh_reports_deleted_for_removed_files() {
6353        let temp = tempfile::tempdir().unwrap();
6354        let project_root = temp.path();
6355        let deleted = project_root.join("src/deleted.rs");
6356        fs::create_dir_all(deleted.parent().unwrap()).unwrap();
6357        write_rust_file(&deleted, "deleted_symbol");
6358
6359        let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
6360        fs::remove_file(&deleted).unwrap();
6361
6362        let mut embed = test_vector_for_texts;
6363        let mut progress = |_done: usize, _total: usize| {};
6364        let summary = index
6365            .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
6366            .unwrap();
6367
6368        assert_eq!(summary.deleted, 1);
6369        assert_eq!(summary.changed, 0);
6370        assert_eq!(summary.added, 0);
6371        assert_eq!(summary.total_processed, 1);
6372        assert!(!index.file_mtimes.contains_key(&deleted));
6373        assert!(index.entries.is_empty());
6374    }
6375
6376    #[test]
6377    fn refresh_reports_changed_for_modified_files() {
6378        let temp = tempfile::tempdir().unwrap();
6379        let project_root = temp.path();
6380        let file = project_root.join("src/lib.rs");
6381        fs::create_dir_all(file.parent().unwrap()).unwrap();
6382        write_rust_file(&file, "old_symbol");
6383
6384        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6385        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
6386        write_rust_file(&file, "new_symbol");
6387
6388        let mut embed = test_vector_for_texts;
6389        let mut progress = |_done: usize, _total: usize| {};
6390        let summary = index
6391            .refresh_stale_files(
6392                project_root,
6393                std::slice::from_ref(&file),
6394                &mut embed,
6395                8,
6396                &mut progress,
6397            )
6398            .unwrap();
6399
6400        assert_eq!(summary.changed, 1);
6401        assert_eq!(summary.added, 0);
6402        assert_eq!(summary.deleted, 0);
6403        assert_eq!(summary.total_processed, 1);
6404        assert!(index
6405            .entries
6406            .iter()
6407            .any(|entry| entry.chunk.name == "new_symbol"));
6408        assert!(!index
6409            .entries
6410            .iter()
6411            .any(|entry| entry.chunk.name == "old_symbol"));
6412    }
6413
6414    #[test]
6415    fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
6416        let temp = tempfile::tempdir().unwrap();
6417        let project_root = temp.path();
6418        let file = project_root.join("src/lib.rs");
6419        fs::create_dir_all(file.parent().unwrap()).unwrap();
6420        write_rust_file(&file, "clean_symbol");
6421
6422        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
6423        let original_entries = index.entries.len();
6424        let mut embed_called = false;
6425        let mut embed = |texts: Vec<String>| {
6426            embed_called = true;
6427            test_vector_for_texts(texts)
6428        };
6429        let mut progress = |_done: usize, _total: usize| {};
6430        let summary = index
6431            .refresh_stale_files(
6432                project_root,
6433                std::slice::from_ref(&file),
6434                &mut embed,
6435                8,
6436                &mut progress,
6437            )
6438            .unwrap();
6439
6440        assert!(summary.is_noop());
6441        assert_eq!(summary.total_processed, 1);
6442        assert!(!embed_called);
6443        assert_eq!(index.entries.len(), original_entries);
6444    }
6445
6446    #[test]
6447    fn detects_missing_onnx_runtime_from_dynamic_load_error() {
6448        let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
6449
6450        assert!(is_onnx_runtime_unavailable(message));
6451    }
6452
6453    #[test]
6454    fn formats_missing_onnx_runtime_with_install_hint() {
6455        let message = format_embedding_init_error(
6456            "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
6457        );
6458
6459        assert!(message.starts_with("ONNX Runtime not found. Install via:"));
6460        assert!(message.contains("Original error:"));
6461    }
6462
6463    #[test]
6464    fn interactive_query_budget_is_independent_from_build_timeout() {
6465        let mut config = SemanticBackendConfig {
6466            backend: SemanticBackend::OpenAiCompatible,
6467            model: "test-embedding".to_string(),
6468            base_url: Some("http://127.0.0.1:9".to_string()),
6469            api_key_env: None,
6470            timeout_ms: 0,
6471            query_timeout_ms: 0,
6472            max_batch_size: 64,
6473            max_files: 20_000,
6474        };
6475
6476        let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
6477        let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
6478        assert_eq!(
6479            build_model.timeout_ms(),
6480            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
6481            "background build keeps the longer default embedding timeout"
6482        );
6483        assert_eq!(
6484            query_model.timeout_ms(),
6485            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
6486            "a query-created model remains safe for later background build reuse"
6487        );
6488        assert_eq!(
6489            QueryBudget::from_config(&config).timeout_ms(),
6490            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
6491        );
6492
6493        config.timeout_ms = 60_000;
6494        assert_eq!(
6495            QueryBudget::from_config(&config).timeout_ms(),
6496            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6497            "the build timeout must not affect interactive requests"
6498        );
6499
6500        config.query_timeout_ms = 700;
6501        assert_eq!(QueryBudget::from_config(&config).timeout_ms(), 700);
6502    }
6503
6504    #[test]
6505    fn background_build_embedding_keeps_retry_ladder() {
6506        let (base_url, requests, handle) =
6507            start_slow_embedding_server(EMBEDDING_REQUEST_MAX_ATTEMPTS, Duration::from_millis(300));
6508        let config = SemanticBackendConfig {
6509            backend: SemanticBackend::OpenAiCompatible,
6510            model: "test-embedding".to_string(),
6511            base_url: Some(base_url),
6512            api_key_env: None,
6513            timeout_ms: 100,
6514            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6515            max_batch_size: 64,
6516            max_files: 20_000,
6517        };
6518        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6519
6520        let error = model
6521            .embed(vec!["slow build batch".to_string()])
6522            .expect_err("all slow build attempts should time out");
6523        handle.join().expect("slow embedding server");
6524
6525        assert!(embedding_failure_is_transient(&error), "error: {error}");
6526        assert_eq!(
6527            requests.load(Ordering::SeqCst),
6528            EMBEDDING_REQUEST_MAX_ATTEMPTS,
6529            "background builds must retain the existing retry ladder"
6530        );
6531    }
6532
6533    #[test]
6534    fn openai_compatible_backend_embeds_with_mock_server() {
6535        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
6536            assert!(request_line.starts_with("POST "));
6537            assert_eq!(path, "/v1/embeddings");
6538            "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
6539        });
6540
6541        let config = SemanticBackendConfig {
6542            backend: SemanticBackend::OpenAiCompatible,
6543            model: "test-embedding".to_string(),
6544            base_url: Some(base_url),
6545            api_key_env: None,
6546            timeout_ms: 5_000,
6547            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6548            max_batch_size: 64,
6549            max_files: 20_000,
6550        };
6551
6552        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6553        let vectors = model
6554            .embed(vec!["hello".to_string(), "world".to_string()])
6555            .unwrap();
6556
6557        assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
6558        handle.join().unwrap();
6559    }
6560
6561    /// Regression for issue #36: AFT was sending TWO Content-Type headers
6562    /// on the OpenAI embeddings request — once implicitly via `.json(&body)`
6563    /// and again explicitly via `.header("Content-Type", "application/json")`.
6564    /// reqwest's `.header()` calls `HeaderMap::append`, which produces two
6565    /// headers on the wire. OpenAI's /v1/embeddings endpoint rejects that
6566    /// with `HTTP 400 "you must provide a model parameter"` even though the
6567    /// body actually contains `model`. The fix is to drop the explicit
6568    /// `.header("Content-Type", ...)` call. This test pins that we send
6569    /// exactly one Content-Type header.
6570    #[test]
6571    fn openai_compatible_request_has_single_content_type_header() {
6572        use std::sync::{Arc, Mutex};
6573        let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
6574        let captured_for_thread = Arc::clone(&captured);
6575
6576        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
6577        let addr = listener.local_addr().expect("local addr");
6578        let handle = thread::spawn(move || {
6579            let (mut stream, _) = listener.accept().expect("accept");
6580            let mut buf = Vec::new();
6581            let mut chunk = [0u8; 4096];
6582            let mut header_end = None;
6583            let mut content_length = 0usize;
6584            loop {
6585                let n = stream.read(&mut chunk).expect("read");
6586                if n == 0 {
6587                    break;
6588                }
6589                buf.extend_from_slice(&chunk[..n]);
6590                if header_end.is_none() {
6591                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
6592                        header_end = Some(pos + 4);
6593                        for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
6594                            if let Some(value) = line.strip_prefix("Content-Length:") {
6595                                content_length = value.trim().parse::<usize>().unwrap_or(0);
6596                            }
6597                        }
6598                    }
6599                }
6600                if let Some(end) = header_end {
6601                    if buf.len() >= end + content_length {
6602                        break;
6603                    }
6604                }
6605            }
6606            *captured_for_thread.lock().unwrap() = buf;
6607            let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
6608            let response = format!(
6609                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
6610                body.len(),
6611                body
6612            );
6613            let _ = stream.write_all(response.as_bytes());
6614        });
6615
6616        let config = SemanticBackendConfig {
6617            backend: SemanticBackend::OpenAiCompatible,
6618            model: "text-embedding-3-small".to_string(),
6619            base_url: Some(format!("http://{}", addr)),
6620            api_key_env: None,
6621            timeout_ms: 5_000,
6622            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6623            max_batch_size: 64,
6624            max_files: 20_000,
6625        };
6626        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6627        let _ = model.embed(vec!["probe".to_string()]).unwrap();
6628        handle.join().unwrap();
6629
6630        let bytes = captured.lock().unwrap().clone();
6631        let request = String::from_utf8_lossy(&bytes);
6632
6633        // Lowercase line counts because HTTP headers are case-insensitive
6634        // and reqwest may emit `content-type` in lowercase under HTTP/2.
6635        let content_type_lines = request
6636            .lines()
6637            .filter(|line| {
6638                let lower = line.to_ascii_lowercase();
6639                lower.starts_with("content-type:")
6640            })
6641            .count();
6642        assert_eq!(
6643            content_type_lines, 1,
6644            "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
6645        );
6646
6647        // The body must still include the model field — pin this so a future
6648        // change can't accidentally drop `model` while fixing duplicate headers.
6649        assert!(
6650            request.contains(r#""model":"text-embedding-3-small""#),
6651            "request body should contain model field; full request:\n{request}",
6652        );
6653    }
6654
6655    #[test]
6656    fn ollama_backend_embeds_with_mock_server() {
6657        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
6658            assert!(request_line.starts_with("POST "));
6659            assert_eq!(path, "/api/embed");
6660            "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
6661        });
6662
6663        let config = SemanticBackendConfig {
6664            backend: SemanticBackend::Ollama,
6665            model: "embeddinggemma".to_string(),
6666            base_url: Some(base_url),
6667            api_key_env: None,
6668            timeout_ms: 5_000,
6669            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
6670            max_batch_size: 64,
6671            max_files: 20_000,
6672        };
6673
6674        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
6675        let vectors = model
6676            .embed(vec!["hello".to_string(), "world".to_string()])
6677            .unwrap();
6678
6679        assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
6680        handle.join().unwrap();
6681    }
6682
6683    #[test]
6684    fn read_from_disk_rejects_fingerprint_mismatch() {
6685        let storage = tempfile::tempdir().unwrap();
6686        let project_key = "proj";
6687
6688        let project_root = test_project_root();
6689        let file = project_root.join("src/main.rs");
6690        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
6691        index.entries.push(EmbeddingEntry {
6692            chunk: SemanticChunk {
6693                file: file.clone(),
6694                name: "handle_request".to_string(),
6695                qualified_name: None,
6696                kind: SymbolKind::Function,
6697                start_line: 10,
6698                end_line: 25,
6699                exported: true,
6700                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
6701                snippet: "fn handle_request() {}".to_string(),
6702            },
6703            norm: vector_norm(&[0.1, 0.2, 0.3]),
6704            vector: vec![0.1, 0.2, 0.3],
6705        });
6706        index.dimension = 3;
6707        index
6708            .file_mtimes
6709            .insert(file.clone(), SystemTime::UNIX_EPOCH);
6710        index.file_sizes.insert(file, 0);
6711        index.set_fingerprint(SemanticIndexFingerprint {
6712            backend: "openai_compatible".to_string(),
6713            model: "test-embedding".to_string(),
6714            base_url: "http://127.0.0.1:1234/v1".to_string(),
6715            dimension: 3,
6716            chunking_version: default_chunking_version(),
6717        });
6718        index.write_to_disk(storage.path(), project_key);
6719
6720        let data_path = storage
6721            .path()
6722            .join("semantic")
6723            .join(project_key)
6724            .join("semantic.bin");
6725        let before = fs::read(&data_path).unwrap();
6726
6727        let matching = index.fingerprint().unwrap().as_string();
6728        assert!(SemanticIndex::read_from_disk(
6729            storage.path(),
6730            project_key,
6731            &project_root,
6732            false,
6733            Some(&matching),
6734        )
6735        .is_some());
6736
6737        let mismatched = SemanticIndexFingerprint {
6738            backend: "ollama".to_string(),
6739            model: "embeddinggemma".to_string(),
6740            base_url: "http://127.0.0.1:11434".to_string(),
6741            dimension: 3,
6742            chunking_version: default_chunking_version(),
6743        }
6744        .as_string();
6745        assert!(SemanticIndex::read_from_disk(
6746            storage.path(),
6747            project_key,
6748            &project_root,
6749            false,
6750            Some(&mismatched),
6751        )
6752        .is_none());
6753        assert_eq!(fs::read(&data_path).unwrap(), before);
6754    }
6755
6756    #[test]
6757    fn fingerprint_mismatch_details_redact_base_url_and_list_changed_fields() {
6758        let cached = SemanticIndexFingerprint {
6759            backend: "openai_compatible".to_string(),
6760            model: "cached-model".to_string(),
6761            base_url: "https://user:secret@example.com/v1/embeddings".to_string(),
6762            dimension: 3,
6763            chunking_version: 2,
6764        };
6765        let current = SemanticIndexFingerprint {
6766            backend: "ollama".to_string(),
6767            model: "current-model".to_string(),
6768            base_url: "https://example.org/api/embed".to_string(),
6769            dimension: 4,
6770            chunking_version: 3,
6771        };
6772
6773        let details = format_fingerprint_mismatch_details(Some(&cached), &current);
6774
6775        assert!(details.contains("backend kind cached=openai_compatible current=ollama"));
6776        assert!(details.contains("model cached=cached-model current=current-model"));
6777        assert!(details.contains("base_url host cached=example.com current=example.org"));
6778        assert!(details.contains("dimension cached=3 current=4"));
6779        assert!(details.contains("chunking version cached=2 current=3"));
6780        assert!(!details.contains("secret"));
6781        assert!(!details.contains("/v1/embeddings"));
6782        assert!(!details.contains("/api/embed"));
6783    }
6784
6785    #[test]
6786    fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
6787        let storage = tempfile::tempdir().unwrap();
6788        let project_key = "proj-v3";
6789        let dir = storage.path().join("semantic").join(project_key);
6790        fs::create_dir_all(&dir).unwrap();
6791
6792        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
6793        index.entries.push(EmbeddingEntry {
6794            chunk: SemanticChunk {
6795                file: PathBuf::from("/src/main.rs"),
6796                name: "handle_request".to_string(),
6797                qualified_name: None,
6798                kind: SymbolKind::Function,
6799                start_line: 0,
6800                end_line: 0,
6801                exported: true,
6802                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
6803                snippet: "fn handle_request() {}".to_string(),
6804            },
6805            norm: vector_norm(&[0.1, 0.2, 0.3]),
6806            vector: vec![0.1, 0.2, 0.3],
6807        });
6808        index.dimension = 3;
6809        index
6810            .file_mtimes
6811            .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
6812        index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
6813        let fingerprint = SemanticIndexFingerprint {
6814            backend: "fastembed".to_string(),
6815            model: "test".to_string(),
6816            base_url: FALLBACK_BACKEND.to_string(),
6817            dimension: 3,
6818            chunking_version: default_chunking_version(),
6819        };
6820        index.set_fingerprint(fingerprint.clone());
6821
6822        let mut bytes = index.to_bytes();
6823        bytes[0] = SEMANTIC_INDEX_VERSION_V3;
6824        let data_path = dir.join("semantic.bin");
6825        fs::write(&data_path, &bytes).unwrap();
6826
6827        assert!(SemanticIndex::read_from_disk(
6828            storage.path(),
6829            project_key,
6830            &test_project_root(),
6831            false,
6832            Some(&fingerprint.as_string())
6833        )
6834        .is_none());
6835        assert_eq!(fs::read(&data_path).unwrap(), bytes);
6836    }
6837
6838    fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
6839        crate::symbols::Symbol {
6840            name: name.to_string(),
6841            kind,
6842            range: crate::symbols::Range {
6843                start_line: start,
6844                start_col: 0,
6845                end_line: end,
6846                end_col: 0,
6847            },
6848            signature: None,
6849            scope_chain: Vec::new(),
6850            exported: false,
6851            parent: None,
6852        }
6853    }
6854
6855    #[test]
6856    fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
6857        let project_root = PathBuf::from("/proj");
6858        let file = project_root.join("src/engine.ts");
6859        let source = "class Index {\n}\n";
6860        let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
6861        symbol.scope_chain = vec!["Engine".to_string()];
6862        symbol.signature = Some("class Index".to_string());
6863        let embed_text = build_embed_text(&symbol, source, &file, &project_root);
6864
6865        let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
6866        let chunk = chunks
6867            .iter()
6868            .find(|chunk| chunk.name == "Index")
6869            .expect("class chunk");
6870
6871        assert_eq!(chunk.name, "Index");
6872        assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
6873        assert_eq!(chunk.embed_text, embed_text);
6874        assert!(!chunk.embed_text.contains("Engine.Index"));
6875    }
6876
6877    /// Heading symbols (Markdown / HTML headings) must NOT be indexed —
6878    /// they overwhelmingly dominated semantic results even on code-shaped
6879    /// queries because heading prose embeds far more strongly than code
6880    /// chunks. Skipping headings keeps aft_search a code-finder.
6881    #[test]
6882    fn symbols_to_chunks_skips_heading_symbols() {
6883        let project_root = PathBuf::from("/proj");
6884        let file = project_root.join("README.md");
6885        let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
6886
6887        let symbols = vec![
6888            make_symbol(SymbolKind::Heading, "Title", 0, 2),
6889            make_symbol(SymbolKind::Heading, "Section", 4, 6),
6890        ];
6891
6892        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
6893        assert!(
6894            chunks.is_empty(),
6895            "Heading symbols must be filtered out before embedding; got {} chunk(s)",
6896            chunks.len()
6897        );
6898    }
6899
6900    /// A symbol with an enormous signature (e.g. a YAML/Kubernetes CronJob
6901    /// whose inline `command:` script is parsed into the signature) must not
6902    /// produce an embed_text that overflows the embedding backend's physical
6903    /// batch. Before the clamp, the unbounded `signature:` append created a
6904    /// multi-KB input that aborted the whole index build and degraded every
6905    /// search to lexical-only.
6906    #[test]
6907    fn build_embed_text_clamps_oversized_signature() {
6908        let project_root = PathBuf::from("/proj");
6909        let file = project_root.join("cronjob.yaml");
6910        let huge_sig = "kubectl ".repeat(2000); // ~16 KB
6911        let source = "apiVersion: batch/v1\nkind: CronJob\n";
6912
6913        let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
6914        symbol.signature = Some(huge_sig);
6915
6916        let text = build_embed_text(&symbol, source, &file, &project_root);
6917        assert!(
6918            text.chars().count() <= MAX_EMBED_TEXT_CHARS,
6919            "embed_text must be clamped to {} chars, got {}",
6920            MAX_EMBED_TEXT_CHARS,
6921            text.chars().count()
6922        );
6923    }
6924
6925    /// Code symbols (functions, classes, methods, structs, etc.) must still
6926    /// be indexed alongside the heading skip — otherwise we'd starve the
6927    /// index entirely.
6928    #[test]
6929    fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
6930        let project_root = PathBuf::from("/proj");
6931        let file = project_root.join("src/lib.rs");
6932        let source = "pub fn handle_request() -> bool {\n    true\n}\n";
6933
6934        let symbols = vec![
6935            // A heading mixed in (e.g. from a doc comment block elsewhere).
6936            make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
6937            make_symbol(SymbolKind::Function, "handle_request", 0, 2),
6938            make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
6939        ];
6940
6941        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
6942        assert_eq!(
6943            chunks.len(),
6944            3,
6945            "Expected file-summary + 2 code chunks (Function + Struct), got {}",
6946            chunks.len()
6947        );
6948        let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
6949        assert!(chunks
6950            .iter()
6951            .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
6952        assert!(names.contains(&"handle_request"));
6953        assert!(names.contains(&"AuthService"));
6954        assert!(
6955            !names.contains(&"doc heading"),
6956            "Heading symbol leaked into chunks: {names:?}"
6957        );
6958    }
6959
6960    #[test]
6961    fn validate_ssrf_allows_loopback_hostnames() {
6962        // Loopback hostnames are explicitly allowed so self-hosted backends
6963        // (Ollama at http://localhost:11434) work at their default config.
6964        for host in &[
6965            "http://localhost",
6966            "http://localhost:8080",
6967            "http://localhost:11434", // Ollama default
6968            "http://localhost.localdomain",
6969            "http://foo.localhost",
6970        ] {
6971            assert!(
6972                validate_base_url_no_ssrf(host).is_ok(),
6973                "Expected {host} to be allowed (loopback), got: {:?}",
6974                validate_base_url_no_ssrf(host)
6975            );
6976        }
6977    }
6978
6979    #[test]
6980    fn validate_ssrf_allows_loopback_ips() {
6981        // 127.0.0.0/8 is loopback — by definition same-machine and not an
6982        // SSRF target. Allow it so Ollama at http://127.0.0.1:11434 works.
6983        for url in &[
6984            "http://127.0.0.1",
6985            "http://127.0.0.1:11434", // Ollama default
6986            "http://127.0.0.1:8080",
6987            "http://127.1.2.3",
6988        ] {
6989            let result = validate_base_url_no_ssrf(url);
6990            assert!(
6991                result.is_ok(),
6992                "Expected {url} to be allowed (loopback), got: {:?}",
6993                result
6994            );
6995        }
6996    }
6997
6998    #[test]
6999    fn validate_ssrf_rejects_private_non_loopback_ips() {
7000        // Non-loopback private/reserved IPs remain rejected — homelab/intranet
7001        // services on LAN IPs are real SSRF targets even though the user
7002        // configured them. Users who want this can opt in by binding the
7003        // service to a public-routable address.
7004        for url in &[
7005            "http://192.168.1.1",
7006            "http://10.0.0.1",
7007            "http://172.16.0.1",
7008            "http://169.254.169.254",
7009            "http://100.64.0.1",
7010        ] {
7011            let result = validate_base_url_no_ssrf(url);
7012            assert!(
7013                result.is_err(),
7014                "Expected {url} to be rejected (non-loopback private), got: {:?}",
7015                result
7016            );
7017        }
7018    }
7019
7020    #[test]
7021    fn validate_ssrf_rejects_mdns_local_hostnames() {
7022        // mDNS .local hostnames typically resolve to LAN devices, not
7023        // loopback. Rejecting them before DNS lookup gives a clearer error.
7024        for host in &[
7025            "http://printer.local",
7026            "http://nas.local:8080",
7027            "http://homelab.local",
7028        ] {
7029            let result = validate_base_url_no_ssrf(host);
7030            assert!(
7031                result.is_err(),
7032                "Expected {host} to be rejected (mDNS), got: {:?}",
7033                result
7034            );
7035        }
7036    }
7037
7038    #[test]
7039    fn normalize_base_url_allows_localhost_for_tests() {
7040        // normalize_base_url itself should NOT block localhost — only
7041        // validate_base_url_no_ssrf does. Tests construct backends directly.
7042        assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
7043        assert!(normalize_base_url("http://localhost:8080").is_ok());
7044    }
7045
7046    #[test]
7047    fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
7048        use std::net::IpAddr;
7049        let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
7050
7051        // Private / link-local / CGNAT — blocked (unchanged behavior).
7052        assert!(blocked("10.0.0.1"));
7053        assert!(blocked("192.168.1.1"));
7054        assert!(blocked("169.254.0.1"));
7055        assert!(blocked("100.64.0.1"));
7056        // Newly covered by delegating to url_fetch's complete list:
7057        assert!(
7058            blocked("198.18.0.1"),
7059            "RFC2544 benchmark range must be blocked"
7060        );
7061        assert!(blocked("224.0.0.1"), "multicast must be blocked");
7062        assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
7063        assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
7064
7065        // Loopback — allowed (local Ollama endpoint), incl. IPv4-mapped form.
7066        assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
7067        assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
7068        assert!(
7069            !blocked("::ffff:127.0.0.1"),
7070            "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
7071        );
7072
7073        // A public address must NOT be flagged.
7074        assert!(!blocked("8.8.8.8"));
7075    }
7076
7077    /// Pin the user-facing wording of the ONNX version-mismatch error.
7078    /// The auto-fix path MUST be listed first because it's the only safe
7079    /// option that doesn't require sudo or risk breaking other apps that
7080    /// link the system library. Regression of any of these strings would
7081    /// either mislead users (system rm before auto-fix) or break the
7082    /// `aft doctor --fix` discovery path.
7083    #[test]
7084    fn ort_mismatch_message_recommends_auto_fix_first() {
7085        let msg =
7086            format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
7087
7088        // The reported version and path must appear verbatim.
7089        assert!(
7090            msg.contains("v1.9.0"),
7091            "should report detected version: {msg}"
7092        );
7093        assert!(
7094            msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
7095            "should report system path: {msg}"
7096        );
7097        assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
7098
7099        // Solution ordering: auto-fix is #1, system rm is #2, install is #3.
7100        let auto_fix_pos = msg
7101            .find("Auto-fix")
7102            .expect("Auto-fix solution missing — users won't discover --fix");
7103        let remove_pos = msg
7104            .find("Remove the old library")
7105            .expect("system-rm solution missing");
7106        assert!(
7107            auto_fix_pos < remove_pos,
7108            "Auto-fix must come before manual rm — see PR comment thread"
7109        );
7110
7111        // The auto-fix command must be runnable as-is on a fresh system.
7112        assert!(
7113            msg.contains("npx @cortexkit/aft doctor --fix"),
7114            "auto-fix command must be present and copy-pasteable: {msg}"
7115        );
7116    }
7117
7118    #[cfg(any(target_os = "linux", target_os = "macos"))]
7119    #[test]
7120    fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
7121        let requested = "libonnxruntime.so";
7122        let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
7123
7124        assert_eq!(detect_ort_version_from_path(requested), None);
7125        let (version, source) =
7126            detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
7127
7128        assert_eq!(version, Some("1.19.0".to_string()));
7129        assert_eq!(source, actual);
7130
7131        let msg = format_ort_version_mismatch(&version.unwrap(), &source);
7132        assert!(msg.contains("v1.19.0"));
7133        assert!(msg.contains(actual));
7134    }
7135
7136    /// macOS dylib paths must not produce a malformed message when the
7137    /// system path lacks a trailing slash. This is a regression guard
7138    /// for the "{}\n{}" format string contract.
7139    #[test]
7140    fn ort_mismatch_message_handles_macos_dylib_path() {
7141        let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
7142        assert!(msg.contains("v1.9.0"));
7143        assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
7144        // The dylib path must appear in the auto-fix paragraph (single
7145        // quotes around it) AND in the manual-rm paragraph; verify
7146        // both placements survived the format string.
7147        assert!(
7148            msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
7149            "system path should be quoted in the auto-fix sentence: {msg}"
7150        );
7151    }
7152}