Skip to main content

aft/
semantic_index.rs

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