Skip to main content

aft/
semantic_index.rs

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