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